Kristian WindandClaude Opus 5 4aa8bd6a40
Release / Build and publish image (push) Failing after 9m54s
fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og
hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret.
Det var en fungerende skanner af det interne net for enhver bruger: peg den på en
vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det
det meste af, hvad der er værd at nå.

Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren
har stadig brug for beskeden — for en lokal model er den som regel selve
problemet — så den er der, bare kun for den, der kører instansen.

**M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på
en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og
serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med
`user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne
overtage det — offerets telefon ville få angriberens beskeder og ingen af sine
egne. Og `DeletePushSubscription` havde ingen ejer-kontrol.

**H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr.
ord uden grænse; en søgning på en kvart megabyte blev til to megabyte
MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun
fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig
for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk —
halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én
forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten
læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til
seksogtres gigabyte skrevet ned på den disk, databasen ligger på.

**M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i
adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på
et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en
fil under en andens kommentar i et projekt, hun kun må læse.

**M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og
AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives.
Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise
værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu
skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at
adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen
virker.

Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde
XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der
rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning,
uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'`
står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:39:52 +02:00

verdande

Self-hosted tasks and projects, shared with the people you share the work with.
One binary, SQLite inside it, deployed as a Rune in Yggdrasil Panel.

The Today view: projects, filters and labels in the sidebar, beside the tasks due today.


Verdande is one of the three Norns — the one who spins that which is becoming. Not what has been, and not what is fated to be: the thing you are in the middle of. Which is what a to-do list is.

Status

All six phases are built. See What works today for what is tested and what is not.

Documentation: docs/, built with Material for MkDocs.

Why this exists

Todoist Pro, self-hosted, on your own hardware, with your own data — and with the integrations that a hosted product will not build for you: a real CalDAV server, a mail-to-task address, and an MCP endpoint so Claude can work your task list directly.

What it does

  • Quick add that reads what you type. betal moms i morgen kl 10 p1 #Firma @regnskab becomes a task called "betal moms", due tomorrow at 10:00, priority 1, in Firma, labelled regnskab. Danish and English, mixed freely in one line.
  • Projects, sections, sub-tasks, labels, saved filters. List, board and calendar views, and foldable groups over the projects in the sidebar.
  • Notes, beside the work. Rich text that is Markdown underneath — headings, lists, quotes, fenced code. A note is shared by filing it in a project; #project and [[another note]] are links, and a task shows the notes that mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through the exporter in tools/ — and comes back out the same shape.
  • Drag where it means something. Reorder a list or a board, file a project under a group, drop a task on another project, or drop it on another day.
  • Sharing. Owner, editor and viewer roles. There is no open registration: everybody arrives through an invite link, either to a project or — from Settings → Brugere — to the instance itself. Nobody's password is ever chosen by somebody else.
  • Live sync. A change made by somebody else appears without a refresh.
  • Recurring tasks as RRULE (RFC 5545), reminders, comments and attachments.
  • Standards, not homegrown formats. iCalendar and CalDAV, ICS feeds, OAuth2 with PKCE, OpenAPI 3.1, MCP, Web Push, Argon2id.
  • A PWA that works on a phone, installable, with swipe gestures and offline reading of what it has already cached.

What works today

Every phase in the plan is built. You can install it, add tasks by typing a sentence, share projects, watch somebody else's changes appear live, set repeating chores and reminders, save filters, import your Todoist account, sync two-way with Apple Reminders, forward mail into it, and add it to Claude as a connector.

Area State
SQLite schema, migrations, WAL Done, tested
Full-text search, Danish-aware Done, tested
Quick-add parser (Danish + English) Done, tested
Argon2id passwords, sessions, TOTP, recovery codes Done, tested
Project roles and permission checks Done, tested
REST API: projects, sections, tasks, sub-tasks, ordering Done, tested
Today, Upcoming, search, activity log Done, tested
Waiting on others: what you have delegated, by person Done, tested
Assignee shown on every task that is somebody else's Done, smoke-tested
Activity log, visible per project Done, smoke-tested
Change a member's role without removing them Done, tested
Sharing: invites, roles, members Done, tested
Live sync over WebSocket Done
Web interface: sign-in, views, quick add, ⌘K, shortcuts Done
Task detail: description, sub-tasks, comments, files, reminders Done
Settings: account, notifications, integrations, AI, tokens, data Done
Device list: where you are signed in, and signing one out Done, tested
User administration: invite to the instance, promote, remove Done, tested
Server errors kept in the database, readable after a restart Done, tested
PWA icons: PNG for launchers, apple-touch-icon for iOS Done, smoke-tested
Invite and password-reset links that open the right page Done, smoke-tested
Drag to reorder, in both the list and the board Done
Drag a task to another project or another day Done, smoke-tested
Foldable project groups in the sidebar Done, tested
Month view on Upcoming, not only per project Done, smoke-tested
Recurring tasks (RRULE, Danish + English) Done, tested
Reminders, absolute and relative Done, tested
Saved filters with a query language Done, tested
Board and calendar views Done
ICS calendar feed, task durations Done, tested
Project templates Done, tested
Nightly backups with rotation Done, tested
Comments, attachments, notifications, Web Push Built
Notes: rich text, Markdown on disk, links to projects, notes and tasks Done, tested
Notes in and out: a zip of Markdown, both directions Done, tested
Todoist CSV import and export, account export Done, tested
MCP server for Claude Done, tested
CalDAV server (two-way VTODO) Done, tested
Mail-to-task Done, tested
AI layer (Anthropic/OpenAI/Google/local) Built, degrades when unconfigured
Gmail: OAuth2 with PKCE, polling, one-way to tasks Done, tested
Google Calendar: read-only, laid over the task calendar Built, never run against Google
Update notice for administrators Done, tested
Personal API tokens Done, tested
OpenAPI 3.1 spec, checked against the router Done, tested
End-to-end smoke tests (Playwright) Done
Documentation, landing page, licence Done

Not finished: Web Push, the AI providers, and the Gmail and Google Calendar API calls are implemented against their specifications and have only ever been exercised there, not against live services. Everything else has an interface: opening a task shows its description, sub-tasks, comments, files and reminders, and the settings pages cover the account, notifications, integrations, AI, API tokens, import, export and templates.

Search finds grøn when you type gron, because Unicode does not consider ø an accented o and a Danish-first app that cannot do this is broken for its own users.

What it looks like

Board view Light theme
Board view — one column per section. The light theme.
A week on Upcoming A task, opened
A week at a time, so a task can be dragged across a month boundary. A task, opened: description, priority, labels, sub-tasks and its files.
Notes The same note as Markdown
Notes, filed in a project to share them. The same note as its source — which is what the export writes to disk.
Themes and looks Instance history
Five themes and four looks: how bright, and how it reads. What has been done on the server.

Regenerate these with node scripts/generate-screenshots.js from web/. It starts the real binary, creates its own account and a second one to share with, fills them with demo data and photographs fourteen surfaces — nothing to set up and nothing to remember. (go run ./tools/shots still works, and is the way to photograph an instance that is already running.)

Running it

As a Rune in Yggdrasil Panel

verdande is built to be deployed as a Rune in Yggdrasil Panel — that is its home, and the manifest ships in this repository. It runs anywhere Docker does as well.

  1. Runes → Carve a rune (upload) and pick rune/verdande.yaml.
  2. Create a server from it.
  3. Set Public address to the address you will actually reach it on. verdande cannot work this out for itself — the host port is not chosen until the server exists — and invite links, password resets and calendar feeds are all built from it. {{PUBLIC_URL}} is the default and is usually right.
  4. Point the SMTP settings at your mail server. Leave the host blank and verdande shows invite links in the app instead of emailing them, which is fine for a single user and no use at all for sharing.
  5. Start it, open the URL, create the first account. That account is the admin.

With Docker directly

docker run -d --name verdande \
  -p 8080:8080 \
  -v verdande-data:/data \
  -e VERDANDE_BASE_URL=https://todo.example.dk \
  ghcr.io/kristianwind/verdande:latest

From source

go run ./cmd/verdande

Listens on :8080 and puts its data in /data, so for local work set VERDANDE_DATA_DIR somewhere writable:

VERDANDE_DATA_DIR=./data VERDANDE_DEV=true go run ./cmd/verdande

A plain go build produces the API without the web interface — the frontend is embedded only when built with -tags embedweb, so a clean checkout compiles without a Node toolchain installed.

Configuration

Everything is an environment variable; there is no config file. A Rune has nowhere to put one that the operator would ever see.

Variable Default What it does
VERDANDE_BASE_URL http://localhost:8080 The public address. Invite links, password resets and ICS feeds are built from it.
VERDANDE_ADDR :8080 Listen address.
VERDANDE_DATA_DIR /data Database, uploaded files and backups.
VERDANDE_SESSION_TTL 720h How long a login lasts.
VERDANDE_INVITE_TTL 168h How long an invite link stays valid.
VERDANDE_RESET_TTL 1h How long a password-reset link stays valid.
VERDANDE_SMTP_HOST — Blank disables outbound mail; invite links are shown in the app instead.
VERDANDE_SMTP_PORT 587
VERDANDE_SMTP_USER —
VERDANDE_SMTP_PASS —
VERDANDE_SMTP_FROM verdande@localhost Sender address.
VERDANDE_SMTP_STARTTLS true unless port 465 Port 465 is TLS from the first byte; everything else negotiates.
VERDANDE_SMTP_INSECURE false Skip certificate verification. For a self-signed internal mail server, and nothing else.
VERDANDE_GMAIL_CLIENT_ID — OAuth client from Google Cloud, if anyone will connect a mailbox.
VERDANDE_GMAIL_CLIENT_SECRET —
VERDANDE_UPDATE_CHECK false Ask GitHub whether a newer release exists. Sends nothing about your instance.
VERDANDE_PANEL_URL — The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings.
VERDANDE_PANEL_TOKEN — An API token from that panel, belonging to somebody with control of this server.
VERDANDE_PANEL_SERVER_ID — Which server to restart — the id in the panel's URL.
VERDANDE_DEV false Human-readable logs at debug level.

What lives in /data

/data
├── verdande.db      SQLite, WAL mode
├── files/           attachments
└── backups/         nightly snapshots, 14 days

Back up the whole directory. If you copy verdande.db on its own while the server is running you will get a database missing its most recent writes — the -wal file is part of it.

Roadmap

  1. MVP — auth, projects, sections, tasks, sub-tasks, priorities, due dates, quick add, Today and Upcoming, search, keyboard shortcuts. (done)
  2. Sharing — invites, roles, assignees, comments, attachments, activity log, realtime sync, notifications. (done)
  3. Pro parity — recurring tasks, reminders, filter queries, board and calendar views, durations, ICS feeds, templates, automatic backups. (done)
  4. Import/export — Todoist CSV and official export in, CSV/JSON/ICS out. (done)
  5. Integrations — CalDAV server, mail-to-task, Gmail, MCP server for Claude, and a provider-agnostic AI layer that also talks to a local model. (done)
  6. Publication — documentation, landing page, open-source scaffolding. (done)

Development

go test ./...          # everything
go test -race ./...    # what CI runs
gofmt -l .             # must print nothing

Working on the interface means two processes: the API on 8080, and Vite on 5173 proxying /api to it, so the session cookie stays first-party exactly as it is in production.

VERDANDE_DATA_DIR=./data VERDANDE_DEV=true go run ./cmd/verdande
cd web && npm install && npm run dev

To run the whole thing as one binary the way it ships:

cd web && npm run build && cd .. && cp -r web/build cmd/verdande/webbuild && go build -tags embedweb -o verdande ./cmd/verdande

Commits follow Conventional Commits; releases are semver tags, which build and publish the image.

Non-goals

Karma and points. Team workspaces and organisations. Native apps. Mail integrations beyond Gmail and the forwarding address. Languages beyond Danish and English. A sync protocol of its own — CalDAV and the REST API are the sync.

License

MIT. See CONTRIBUTING.md and SECURITY.md.

S
Description
No description provided
Readme MIT
60 MiB
Languages
Go 57.1%
Svelte 20.4%
JavaScript 19.3%
HTML 1.7%
Python 0.7%
Other 0.8%