Files
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 <[email protected]>
2026-08-20 08:39:52 +02:00

416 lines
12 KiB
Go

// Package filterquery parses the saved-filter language: the small expression
// somebody types to describe a slice of their tasks.
//
// today & p1
// #Firma & @regnskab
// overdue | (today & assigned to: me)
// 7 days & !@venter
//
// It compiles to SQL rather than filtering in Go, so a filter over ten thousand
// tasks is one indexed query instead of ten thousand rows crossing the boundary.
//
// The grammar is deliberately Todoist's, because that is the one people already
// know and the import path from it should not require relearning anything:
//
// expression := term (('&' | '|' | ',') term)*
// term := '!' term | '(' expression ')' | atom
// atom := #project | @label | pN | date-word | 'assigned to:' name | text
//
// '&' binds tighter than '|', and ',' is Todoist's spelling of '|'.
package filterquery
import (
"fmt"
"strconv"
"strings"
"time"
"unicode"
)
// Compiled is a WHERE fragment and its arguments, ready to be dropped into the
// task query. The fragment references the task table as `t`.
type Compiled struct {
SQL string
Args []any
}
// Context is what the query needs to know that is not in the text: who is asking,
// and what "today" means where they are.
type Context struct {
UserID string
Now time.Time
Location *time.Location
}
func (c Context) today() time.Time {
loc := c.Location
if loc == nil {
loc = time.UTC
}
now := c.Now.In(loc)
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
// Parse compiles a filter expression.
//
// An error names what went wrong in the same terms the person typed, because this
// is a language they are writing by hand in a small box with no autocomplete.
func Parse(query string, ctx Context) (Compiled, error) {
p := &parser{tokens: tokenize(query), ctx: ctx}
if len(p.tokens) == 0 {
return Compiled{}, fmt.Errorf("filteret er tomt")
}
// Refused before it is parsed, not while.
//
// `combine` rebuilds the whole accumulated SQL string and re-copies the
// argument slice per operator, so the cost is quadratic in the number of
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
// query string — took seven seconds of CPU and forty-six gigabytes of
// allocation in one request. The preview endpoint runs this on whatever is
// typed, and there is no write timeout on the server to cut it off.
//
// Two hundred terms is a filter nobody has ever written and far below where it
// costs anything.
if len(p.tokens) > maxTokens {
return Compiled{}, fmt.Errorf("filteret er for langt")
}
node, err := p.parseExpression()
if err != nil {
return Compiled{}, err
}
if !p.done() {
return Compiled{}, fmt.Errorf("kunne ikke læse %q", p.peek().text)
}
return node, nil
}
// --- tokens -------------------------------------------------------------------
type tokenKind int
const (
tokWord tokenKind = iota
tokAnd
tokOr
tokNot
tokLParen
tokRParen
)
type token struct {
kind tokenKind
text string
}
func tokenize(query string) []token {
var out []token
runes := []rune(query)
for i := 0; i < len(runes); {
r := runes[i]
switch {
case unicode.IsSpace(r):
i++
case r == '&':
out = append(out, token{kind: tokAnd, text: "&"})
i++
case r == '|', r == ',':
out = append(out, token{kind: tokOr, text: string(r)})
i++
case r == '!':
out = append(out, token{kind: tokNot, text: "!"})
i++
case r == '(':
out = append(out, token{kind: tokLParen, text: "("})
i++
case r == ')':
out = append(out, token{kind: tokRParen, text: ")"})
i++
default:
// A word runs to the next operator. That is what lets "assigned to:
// anders" and "7 days" arrive as single atoms rather than as three
// fragments the parser would then have to reassemble.
start := i
for i < len(runes) && !isOperator(runes[i]) {
i++
}
text := strings.TrimSpace(string(runes[start:i]))
if text != "" {
out = append(out, token{kind: tokWord, text: text})
}
}
}
return out
}
func isOperator(r rune) bool {
switch r {
case '&', '|', ',', '!', '(', ')':
return true
}
return false
}
// --- parser -------------------------------------------------------------------
// The ceilings on one filter. See Parse for why.
const (
maxTokens = 200
maxDepth = 32
)
type parser struct {
tokens []token
pos int
ctx Context
// depth guards against a filter that is only parentheses. Each level is a
// stack frame, and nothing else stops somebody sending a megabyte of them.
depth int
}
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
func (p *parser) peek() token { return p.tokens[p.pos] }
func (p *parser) next() token { t := p.tokens[p.pos]; p.pos++; return t }
// parseExpression handles '|' — the loosest binding, so it is the outermost level.
func (p *parser) parseExpression() (Compiled, error) {
left, err := p.parseAnd()
if err != nil {
return Compiled{}, err
}
for !p.done() && p.peek().kind == tokOr {
p.next()
right, err := p.parseAnd()
if err != nil {
return Compiled{}, err
}
left = combine("OR", left, right)
}
return left, nil
}
func (p *parser) parseAnd() (Compiled, error) {
left, err := p.parseTerm()
if err != nil {
return Compiled{}, err
}
for !p.done() && p.peek().kind == tokAnd {
p.next()
right, err := p.parseTerm()
if err != nil {
return Compiled{}, err
}
left = combine("AND", left, right)
}
return left, nil
}
func (p *parser) parseTerm() (Compiled, error) {
if p.done() {
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
}
// Nesting is stack depth. A filter made of half a million opening parentheses
// is a megabyte of text and a stack frame for each.
p.depth++
defer func() { p.depth-- }()
if p.depth > maxDepth {
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
}
switch t := p.peek(); t.kind {
case tokNot:
p.next()
inner, err := p.parseTerm()
if err != nil {
return Compiled{}, err
}
return Compiled{SQL: "NOT (" + inner.SQL + ")", Args: inner.Args}, nil
case tokLParen:
p.next()
inner, err := p.parseExpression()
if err != nil {
return Compiled{}, err
}
if p.done() || p.peek().kind != tokRParen {
return Compiled{}, fmt.Errorf("der mangler en afsluttende parentes")
}
p.next()
return Compiled{SQL: "(" + inner.SQL + ")", Args: inner.Args}, nil
case tokRParen:
return Compiled{}, fmt.Errorf("uventet )")
case tokAnd, tokOr:
return Compiled{}, fmt.Errorf("%q står hvor der skulle stå et søgeord", t.text)
default:
p.next()
return p.atom(t.text)
}
}
func combine(op string, left, right Compiled) Compiled {
return Compiled{
SQL: "(" + left.SQL + " " + op + " " + right.SQL + ")",
Args: append(append([]any{}, left.Args...), right.Args...),
}
}
// --- atoms --------------------------------------------------------------------
// atom compiles one search term.
//
// Every branch produces parameterised SQL. No user text is ever concatenated into
// the statement — this is a language people type, so it is exactly the place where
// string-building would become an injection.
func (p *parser) atom(text string) (Compiled, error) {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return Compiled{}, fmt.Errorf("tomt søgeord")
}
switch {
case strings.HasPrefix(lower, "#"):
return p.project(strings.TrimPrefix(text, "#"))
case strings.HasPrefix(lower, "@"):
return p.label(strings.TrimPrefix(text, "@"))
case strings.HasPrefix(lower, "assigned to:"), strings.HasPrefix(lower, "tildelt:"):
_, value, _ := strings.Cut(text, ":")
return p.assignee(strings.TrimSpace(value))
case lower == "assigned", lower == "tildelt":
return Compiled{SQL: "t.assignee_id IS NOT NULL"}, nil
case lower == "no date", lower == "ingen dato", lower == "no due date":
return Compiled{SQL: "t.due_date IS NULL"}, nil
case lower == "overdue", lower == "forsinket", lower == "over due":
return Compiled{
SQL: "(t.due_date IS NOT NULL AND t.due_date < ?)",
Args: []any{p.day(0)},
}, nil
case lower == "today", lower == "i dag", lower == "idag":
return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(0)}}, nil
case lower == "tomorrow", lower == "i morgen", lower == "imorgen":
return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(1)}}, nil
case lower == "yesterday", lower == "i går", lower == "igar":
return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(-1)}}, nil
case lower == "recurring", lower == "gentagen", lower == "gentagne":
return Compiled{SQL: "t.recurrence_rule IS NOT NULL"}, nil
case lower == "completed", lower == "færdig", lower == "afsluttet":
return Compiled{SQL: "t.completed_at IS NOT NULL"}, nil
case lower == "subtask", lower == "underopgave":
return Compiled{SQL: "t.parent_id IS NOT NULL"}, nil
}
// pN — priority. Todoist numbers them the way the interface does, p1 highest.
if len(lower) == 2 && lower[0] == 'p' && lower[1] >= '1' && lower[1] <= '4' {
return Compiled{SQL: "t.priority = ?", Args: []any{int(lower[1] - '0')}}, nil
}
// "7 days" / "7 dage" — everything due between now and then, which is how
// Todoist reads it: a window, not a single day.
if n, unit, ok := cutNumberWord(lower); ok {
switch unit {
case "days", "dage", "day", "dag":
return Compiled{
SQL: "(t.due_date IS NOT NULL AND t.due_date >= ? AND t.due_date <= ?)",
Args: []any{p.day(0), p.day(n)},
}, nil
case "weeks", "uger", "week", "uge":
return Compiled{
SQL: "(t.due_date IS NOT NULL AND t.due_date >= ? AND t.due_date <= ?)",
Args: []any{p.day(0), p.day(n * 7)},
}, nil
}
}
// An explicit date.
if _, err := time.Parse("2006-01-02", lower); err == nil {
return Compiled{SQL: "t.due_date = ?", Args: []any{lower}}, nil
}
// Anything left is free text, matched against the task's own words. Falling
// back to a search rather than an error is what keeps the box forgiving: a
// filter that finds too much is fixable, one that refuses to run is not.
return Compiled{
SQL: "(t.content LIKE ? OR t.description LIKE ?)",
Args: []any{"%" + text + "%", "%" + text + "%"},
}, nil
}
func (p *parser) day(offset int) string {
return p.ctx.today().AddDate(0, 0, offset).Format("2006-01-02")
}
// project matches by name, case-insensitively, and only among projects the asking
// user can see — the outer query restricts that, but naming a project here must not
// widen it.
func (p *parser) project(name string) (Compiled, error) {
name = strings.TrimSpace(strings.Trim(name, `"`))
if name == "" {
return Compiled{}, fmt.Errorf("#: der mangler et projektnavn")
}
return Compiled{
SQL: `t.project_id IN (
SELECT id FROM projects WHERE lower(name) = lower(?) AND deleted_at IS NULL)`,
Args: []any{name},
}, nil
}
// label matches the asking user's own labels. Labels are personal, so the same word
// on somebody else's task is a different label and must not match.
func (p *parser) label(name string) (Compiled, error) {
name = strings.TrimSpace(strings.Trim(name, `"`))
if name == "" {
return Compiled{}, fmt.Errorf("@: der mangler et etiketnavn")
}
return Compiled{
SQL: `EXISTS (
SELECT 1 FROM task_labels tl JOIN labels l ON l.id = tl.label_id
WHERE tl.task_id = t.id AND l.user_id = ? AND lower(l.name) = lower(?))`,
Args: []any{p.ctx.UserID, name},
}, nil
}
// assignee resolves "me" to the asking user and anything else to a name or address.
func (p *parser) assignee(who string) (Compiled, error) {
who = strings.TrimSpace(strings.Trim(who, `"`))
switch strings.ToLower(who) {
case "":
return Compiled{}, fmt.Errorf("assigned to: der mangler et navn")
case "me", "mig":
return Compiled{SQL: "t.assignee_id = ?", Args: []any{p.ctx.UserID}}, nil
case "none", "ingen", "nobody":
return Compiled{SQL: "t.assignee_id IS NULL"}, nil
}
return Compiled{
SQL: `t.assignee_id IN (
SELECT id FROM users WHERE lower(email) = lower(?) OR lower(name) = lower(?))`,
Args: []any{who, who},
}, nil
}
// cutNumberWord reads "7 days" into (7, "days").
func cutNumberWord(s string) (int, string, bool) {
fields := strings.Fields(s)
if len(fields) != 2 {
return 0, "", false
}
n, err := strconv.Atoi(fields[0])
if err != nil || n < 0 || n > 3650 {
return 0, "", false
}
return n, fields[1], true
}