fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
Release / Build and publish image (push) Failing after 9m54s

**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>
This commit is contained in:
Kristian Wind
2026-08-20 08:39:52 +02:00
co-authored by Claude Opus 5
parent ce3fc466b2
commit 4aa8bd6a40
13 changed files with 444 additions and 19 deletions
+43 -2
View File
@@ -294,6 +294,14 @@ func noteFilename(title string) string {
// a few megabytes — and small enough that a wrong file is refused rather than read.
const maxNoteImportBytes = 64 << 20
// What the archive may become once it is unpacked, and how many entries it may
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
// nothing at all about what it expands to.
const (
maxNoteImportUnpacked = 2 << 30
maxNoteImportFiles = 20_000
)
// handleImportNotes reads a zip of Markdown files and makes a note of each.
//
// The mirror of the export, and deliberately the same shape: whatever comes out of
@@ -332,24 +340,57 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
// note is read its pictures already have addresses to be rewritten to — a note
// and its images arrive in whatever order the archive happens to hold them, and
// one pass would leave half the links pointing at nothing.
// The archive as a whole, not just the upload.
//
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
// MB can become sixty-six gigabytes on the way out — written into the same
// volume the database lives on. The upload limit says nothing about that, and
// nothing else did either.
//
// Two ceilings: one per file, and one across the archive. A single enormous
// photograph and ten thousand small ones are different attacks with the same
// ending.
if len(zr.File) > maxNoteImportFiles {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that archive holds too many files")
return
}
budget := int64(maxNoteImportUnpacked)
stored := map[string]string{}
for _, f := range zr.File {
name := f.Name
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
continue
}
// The header's own claim first, which costs nothing to check and rejects
// the obvious case before a byte is written. It is only a claim, so the
// reader below is limited as well.
if f.UncompressedSize64 > maxUploadBytes {
s.log.Warn("import: a file in the archive is too large", "file", name)
skipped++
continue
}
rc, err := f.Open()
if err != nil {
continue
}
hashed, size, err := s.storeUpload(rc)
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
rc.Close()
if err != nil {
s.log.Warn("import note file", "err", err, "file", name)
continue
}
if size > maxUploadBytes || size > budget {
// Over the line once it was actually read: remove what was written
// rather than keeping a truncated file that would open as nothing.
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
skipped++
continue
}
budget -= size
stored[name] = hashed
_ = size
files++
}