Skip to content

Security: devstefancho/kanban-ready

Security

SECURITY.md

Security Model & Deployment Constraints

Read this before deploying. This project is intentionally minimal and ships with a security model that fits one specific use case. If your use case is different, the defaults here can leave you exposed.


1. Threat model & assumptions

This project is designed for a single operator running their own instance for personal use. The entire security posture relies on that assumption.

Assumption What it means in practice
Single user There is no concept of "user accounts." Every request that gets past the IP gate has full read/write/delete on every card.
Operator owns the deploy You control the Cloudflare account, the domain, the WAF rules, and wrangler secret. There is no admin/non-admin split.
Card content is yours Cards are stored as plaintext in D1. Don't put anything in them you wouldn't put in a notes app on your laptop.
Trust boundary = IP allowlist The only thing standing between the open internet and your full CRUD API is the IP allowlist (Cloudflare WAF and/or ALLOWED_IPS secret).

If any of those assumptions don't hold for you, the default configuration is not safe. See §5 If you want multi-user.


2. Known limitations (what this project does NOT do)

These are not bugs. They're explicit non-features. Read them before running.

2.1 Authentication is IP-based only

There is no login, no session, no API key, no OAuth. The only authentication signal is the source IP from cf-connecting-ip.

Implications:

  • Anyone on the same NAT'd network as you (same household, same coffee shop wifi, same office) appears with the same public IP and is fully authenticated as "you."
  • If your home IP rotates (most Korean ISPs), you lose access until you update the allowlist. There is no fallback identity.
  • Mobile data, VPNs, travel — all require allowlist updates or temporary rule disable.

2.2 Worker IP gate is fail-open by default

src/worker.ts has this:

if (!raw) return next();                      // ALLOWED_IPS unset → pass
const allowed = raw.split(",").map(s => s.trim()).filter(Boolean);
if (allowed.length === 0) return next();      // value trims to nothing → pass

If you deploy without setting the ALLOWED_IPS secret and without a Cloudflare WAF rule, every API endpoint is open to the internet, including DELETE /api/cards/:id. The same fail-open path triggers if ALLOWED_IPS is set but contains only commas/whitespace (e.g. you tried to "blank it out" instead of running wrangler secret delete).

This is a deliberate trade-off (the operator can temporarily disable the gate without redeploying), but it means first-time deploys are dangerous. See the deploy checklist in §3.

2.2.1 IP-gate trust depends on Cloudflare being the only ingress

The Worker reads the source IP from the cf-connecting-ip request header (worker.ts:29). Cloudflare overwrites that header on every request that goes through its proxy, so it can't be forged from the public internet. However, that guarantee only holds while every incoming request reaches the Worker via a Cloudflare-proxied custom domain. If you ever:

  • re-enable workers_dev in wrangler.toml (currently false — keep it that way),
  • expose the Worker behind a non-Cloudflare frontend / proxy,
  • or call the Worker from a Cloudflare preview deployment without the same WAF rule,

…then cf-connecting-ip is attacker-controllable and the IP gate becomes meaningless. If you add ingress paths in the future, also add a check that the request actually came through your zone (e.g. a shared CF-Access-Client-Id or a stricter WAF rule).

2.3 No per-endpoint authorization

Once a request passes the IP gate, every API endpoint is callable. There is no "read-only" mode, no separate admin path, no rate limiting beyond what Cloudflare provides at the edge.

2.4 No input size limits

title, body, and tags are accepted at any size. A single 100MB POST is allowed. Combined with the absence of rate limiting, this means anyone past the IP gate can exhaust your D1 free-tier quota (50K writes/day, 5GB total) trivially.

2.5 Plaintext storage and backups

  • Card content is stored unencrypted in Cloudflare D1.
  • Backups produced by wrangler d1 export are plaintext SQL files.
  • .gitignore blocks backup-*.sql from being committed, but you must store the backup files themselves somewhere safe (don't drop them in shared cloud folders).

2.6 No audit log

Cards have created/updated timestamps but no history. Deletes are hard deletes. If someone breaches the IP gate and wipes the table, your only recovery is your last manual backup.

2.7 The MCP server is unauthenticated stdio

mcp/server.ts is a thin client that calls the remote API. Anyone who can run that script on your machine can hit your board. This matters if you share an MCP config across machines or commit one to a multi-user environment — the URL alone is enough to call the API (still subject to the IP gate of the calling machine).


3. Deploy checklist (do these before exposing the worker)

Before you wrangler deploy for the first time, all of these must be true:

  • workers_dev = false in wrangler.toml (already set in this repo). This closes the <account>.workers.dev backdoor that bypasses your custom-domain WAF rules.
  • At least one of the IP gates configured — see §4 Setting up the IP gate for the actual setup steps:
    • A Cloudflare WAF Custom Rule blocking everything except your IP(s) on the host, or
    • ALLOWED_IPS Worker secret set with your IP(s).
    • Setting both is recommended (defense in depth — platform-level block + code-level fallback).
  • Verify the gate from outside: after deploying, visit the URL from a network that is not on the allowlist (e.g. mobile data with wifi off). You should get Cloudflare 1020 (WAF block) or HTTP 403 (worker block). If you get a normal response, the gate is not active — stop and fix it before continuing.
  • /api/whoami: from your allowed network, hit https://<your-domain>/api/whoami. The returned IP should match what's in your allowlist. If it doesn't, your WAF rule will silently block you next.
  • First deploy is a destructive write surface: don't put real card content in until the above checks pass. A blank board is a cheap recovery point.

If your IP rotates and you lock yourself out later, see docs/operations.md §1 for recovery.


4. Setting up the IP gate

Two layers; either alone is enough, but you should stack both for defense-in-depth. Without at least one of them active, the API is open to the internet.

4.1 Find your IP

curl https://<your-domain>/api/whoami
# or, before deploy, just:
curl ifconfig.me; echo
curl -6 ifconfig.me; echo   # IPv6 (if your ISP gives one)

4.2 Layer 1 (recommended): Cloudflare WAF Custom Rule

Free plan includes 5 custom rules per zone — plenty for personal use.

  1. https://dash.cloudflare.comWebsites → <your-zone>.
  2. Security → WAF → Custom rules → Create rule.
  3. Name: kanban home only.
  4. Use the Edit expression mode and paste:
    (http.host eq "kanban.your-domain.com") and not (ip.src in {203.0.113.10})
    
    Replace the host and IP with your own. Add IPv6 inside the same set if you have one: {203.0.113.10 2001:xxxx::/64}.
  5. Action: Block.
  6. Save.

Anyone outside the listed IPs gets a Cloudflare 1020 block page before the request reaches the Worker.

4.3 Layer 2: Worker-level secret (ALLOWED_IPS)

Already wired into src/worker.ts. Empty/unset = pass-through (fail-open — see §2.2). Set the secret to activate:

npx wrangler secret put ALLOWED_IPS
# paste comma-separated IPs (no spaces): 203.0.113.10,2001:xxx::/64

Unset to disable:

npx wrangler secret delete ALLOWED_IPS

5. If you want multi-user

This project is not suitable as-is for shared use (team kanban, public SaaS, "just our 3 friends"). To make it safe for multiple users you would need to add, at minimum:

  1. Real authentication — OAuth (e.g. Cloudflare Access, Auth0, GitHub OAuth) or a session-based login. The IP gate is not a substitute.
  2. Per-user data isolation — the cards table has no owner / user_id column. Every user would see and edit every card. Adding ownership requires a migration plus authorization checks in src/core/store.ts.
  3. Rate limiting — Cloudflare's free tier offers some, but you'll want explicit per-user limits to prevent quota exhaustion.
  4. Audit logging — at minimum a card_history table or a write log, so you can answer "who deleted that?"
  5. Input validation — explicit length caps on title, body, tags to prevent abuse.

These are non-trivial changes. If you just need a personal task queue, the current single-user design is the right fit; if you need shared use, you're better off forking and treating the existing code as a starting skeleton, not a production app.


6. Reporting a vulnerability

If you find a security issue in this codebase (not in your own deployment configuration — that's on you), please open an issue on the GitHub repo with the label security, or contact the maintainer privately if the issue is sensitive.

This is a personal project maintained best-effort. There is no SLA on response times.

There aren't any published security advisories