CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s
Grunden er den samme som for postkassers kodeord og OAuth-tokens: en sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til. **Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst. **Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden: `notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk, og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads. **Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne; fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og delordssøgningen falder bort. bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest: titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren optælling laver, og den er let at lave og svær at få øje på. **En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste den rækkefølge væk, importen brugte en dag på at hente hjem. En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans, der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der siger fra. Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden: opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke projekter og opgaver en note peger på, men ikke hvad der står. Co-Authored-By: Claude Opus 5 <[email protected]>
67 lines
3.8 KiB
Markdown
67 lines
3.8 KiB
Markdown
# Security
|
||
|
||
## Reporting
|
||
|
||
Email **[email protected]** rather than opening an issue. Include what you
|
||
found, how to reproduce it, and what an attacker could do with it. You will get a
|
||
reply within a week.
|
||
|
||
## What verdande does about the obvious things
|
||
|
||
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
||
be raised later without locking anyone out.
|
||
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
|
||
database dump contains nothing that can be presented.
|
||
- **The calendar feed address and the mail-to-task address are not hashed.** They
|
||
are looked up by their exact value, and they are shown in the interface more
|
||
than once, which a hash cannot do. Anybody holding a backup file therefore holds
|
||
those two URLs: the feed reads that person's tasks without a login, and the mail
|
||
address files tasks into their inbox. Rotating either from **Settings →
|
||
Integrations** invalidates the old one. This is written down rather than fixed
|
||
because fixing it means the address can only ever be shown once, which is a
|
||
decision about how the feature works and not only about how it is stored.
|
||
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
|
||
AES-GCM under a key kept outside the database — see `internal/secret`. That is
|
||
what makes a stray copy of the database less than a stray copy of everything:
|
||
the backup does not carry the key.
|
||
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
||
- **Uploads** are stored content-addressed by hash and served as
|
||
`application/octet-stream` with `nosniff` — with one exception, added so that a
|
||
note can show the pictures in it: a short allowlist of raster image types is
|
||
served inline as its own type. The list is named one format at a time rather
|
||
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
|
||
through, and an SVG is a document that can run script. `nosniff` stays, so the
|
||
browser must read the file as that image or not at all.
|
||
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
||
concatenated into a statement.
|
||
- **Permission checks** are route middleware, and anything you may not see answers
|
||
404 rather than 403 — a 403 confirms the thing exists.
|
||
- **The CSP** is computed at startup by hashing the inline scripts in the built
|
||
page, rather than allowing `unsafe-inline`.
|
||
|
||
- **Note titles and bodies are sealed** under the same key, since 0027. Both, not
|
||
only the body: a note's title *is* its first line, so a readable title beside a
|
||
sealed body would leave the beginning of every note in the clear.
|
||
|
||
The two plaintext copies went with it. `notes_fts` indexed the body and the
|
||
generated `fold` column held a transliterated copy of it — sealing the body while
|
||
leaving those would have been theatre, since a stolen file could be read straight
|
||
out of the index. Search opens the text in Go instead, which costs 33–43 ms
|
||
across twelve hundred notes and is the wrong shape somewhere around ten thousand.
|
||
- **Task titles, descriptions and comments are still in the clear**, and so is
|
||
`note_links` — ids derived from a note's body, so a stolen file still shows which
|
||
projects and tasks a note points at, though not what it says.
|
||
|
||
## What it does not do
|
||
|
||
Rate limiting covers the endpoints a stranger can reach: signing in, resetting a
|
||
password, the second factor, the installation beacon, and inbound mail. **Nothing
|
||
else is limited** — an authenticated caller can ask as often as they like, and a
|
||
signed-in account that wanted to be a nuisance can be one.
|
||
|
||
There is no audit log of reads. Who *changed* something is recorded per project;
|
||
who looked at it is not.
|
||
|
||
It is built to be run behind something — a Cloudflare Tunnel, a reverse proxy, a
|
||
VPN — by somebody who knows what they exposed.
|