Files
verdande/internal/store/search.go
T
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

119 lines
4.3 KiB
Go

package store
import (
"strings"
"unicode"
)
// FoldDanish transliterates the Danish letters that Unicode does not treat as
// accented forms of anything, so they survive into a form FTS5 can fold further.
//
// This has to be applied to the query with exactly the same mapping the `fold`
// column on `tasks` uses. The two are a pair: change one and searching quietly
// stops finding half the words in the user's own language.
func FoldDanish(s string) string {
r := strings.NewReplacer(
"ø", "o", "Ø", "o",
"æ", "ae", "Æ", "ae",
"å", "aa", "Å", "aa",
)
return r.Replace(s)
}
// The ceilings on a search. See MatchExprOver for why they exist; both are far
// above anything a person types and far below anything that costs.
const (
maxSearchInput = 512
maxSearchTerms = 16
)
// MatchExpr turns what someone typed into the search box into an FTS5 MATCH
// expression.
//
// User input is never passed to FTS5 as-is. The MATCH grammar has its own operators
// (`AND`, `NEAR`, `*`, `^`, `:`, `-`), so a perfectly ordinary task title like
// "budget: q3 - final" parses as a query and fails with a syntax error rather than
// finding anything. Every term is quoted, which makes it a literal.
//
// Each term must match somewhere in the row, and may match either the text as
// written or its folded form — so "gron" finds "grøn" and "grøn" still finds
// itself. Terms are prefix-matched, which is what makes the Cmd+K box feel like it
// is searching as you type.
//
// Returns "" when there is nothing searchable in the input; callers should treat
// that as "no query" rather than as "match everything".
func MatchExpr(query string) string {
return MatchExprOver(query, "content", "description")
}
// MatchExprOver is MatchExpr against a named set of columns.
//
// The columns have to be named because the index they are searched in decides
// them: tasks carry content and description, notes carry title and body. Passing
// one table's column names to the other's index is not a wrong answer but an
// error — "no such column" — which is at least loud.
func MatchExprOver(query string, columns ...string) string {
terms := tokenize(query)
if len(terms) == 0 || len(columns) == 0 {
return ""
}
// Capped, because the expression is built from the input and then run inside
// SQLite on one of a handful of pooled connections.
//
// Every term becomes roughly fifty bytes of FTS5, and every one of them is a
// prefix query — which is what makes ⌘K feel like it searches as you type, and
// also what makes each one expensive. A quarter of a megabyte pasted into the
// box became a two-megabyte MATCH expression and half a minute inside SQLite,
// holding a connection the whole time. A few of those at once and the database
// is unavailable to everybody on the instance, logins included.
//
// Sixteen terms is far past anything a person types into a search box and
// nowhere near enough to hurt. Silently, because a message about a limit
// nobody has reached on purpose is noise.
if len(terms) > maxSearchTerms {
terms = terms[:maxSearchTerms]
}
var b strings.Builder
for i, term := range terms {
if i > 0 {
b.WriteString(" AND ")
}
folded := FoldDanish(term)
b.WriteString(`({` + strings.Join(columns, " ") + `} : `)
b.WriteString(quote(term))
b.WriteString(`* OR fold : `)
b.WriteString(quote(folded))
b.WriteString(`*)`)
}
return b.String()
}
// tokenize splits on everything that is not a letter or a digit. Splitting on
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
func tokenize(query string) []string {
// Trimmed before it is split, so a megabyte of text is not walked rune by rune
// only to have all but sixteen of the results thrown away.
if len(query) > maxSearchInput {
query = query[:maxSearchInput]
}
fields := strings.FieldsFunc(query, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
terms := make([]string, 0, len(fields))
for _, f := range fields {
if f != "" {
terms = append(terms, f)
}
}
return terms
}
// quote wraps a term as an FTS5 string literal. Inside one, the only character
// with meaning is the double quote, escaped by doubling it.
func quote(term string) string {
return `"` + strings.ReplaceAll(term, `"`, `""`) + `"`
}