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.
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.
These are not bugs. They're explicit non-features. Read them before running.
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.
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 → passIf 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.
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_devinwrangler.toml(currentlyfalse— 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).
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.
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.
- Card content is stored unencrypted in Cloudflare D1.
- Backups produced by
wrangler d1 exportare plaintext SQL files. .gitignoreblocksbackup-*.sqlfrom being committed, but you must store the backup files themselves somewhere safe (don't drop them in shared cloud folders).
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.
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).
Before you wrangler deploy for the first time, all of these must be true:
-
workers_dev = falseinwrangler.toml(already set in this repo). This closes the<account>.workers.devbackdoor 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_IPSWorker 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, hithttps://<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.
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.
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)Free plan includes 5 custom rules per zone — plenty for personal use.
- https://dash.cloudflare.com → Websites → <your-zone>.
- Security → WAF → Custom rules → Create rule.
- Name:
kanban home only. - Use the Edit expression mode and paste:
Replace the host and IP with your own. Add IPv6 inside the same set if you have one:
(http.host eq "kanban.your-domain.com") and not (ip.src in {203.0.113.10}){203.0.113.10 2001:xxxx::/64}. - Action: Block.
- Save.
Anyone outside the listed IPs gets a Cloudflare 1020 block page before the request reaches the Worker.
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::/64Unset to disable:
npx wrangler secret delete ALLOWED_IPSThis 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:
- Real authentication — OAuth (e.g. Cloudflare Access, Auth0, GitHub OAuth) or a session-based login. The IP gate is not a substitute.
- Per-user data isolation — the
cardstable has noowner/user_idcolumn. Every user would see and edit every card. Adding ownership requires a migration plus authorization checks insrc/core/store.ts. - Rate limiting — Cloudflare's free tier offers some, but you'll want explicit per-user limits to prevent quota exhaustion.
- Audit logging — at minimum a
card_historytable or a write log, so you can answer "who deleted that?" - Input validation — explicit length caps on
title,body,tagsto 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.
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.