fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
Release / Build and publish image (push) Failing after 9m54s
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:
co-authored by
Claude Opus 5
parent
ce3fc466b2
commit
4aa8bd6a40
@@ -140,11 +140,17 @@ func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) e
|
||||
if sub.ID == "" {
|
||||
sub.ID = NewID()
|
||||
}
|
||||
// The upsert no longer moves the row between accounts.
|
||||
//
|
||||
// `user_id = excluded.user_id` was in the DO UPDATE, so anybody who learned
|
||||
// another person's endpoint could claim it by subscribing with it: the
|
||||
// victim's device would then receive the claimant's notifications and none of
|
||||
// its own. A re-subscribe from the same person still refreshes the keys, which
|
||||
// is what the upsert is actually for.
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
@@ -175,7 +181,21 @@ func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushS
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error {
|
||||
// DeletePushSubscription removes one of this person's devices.
|
||||
//
|
||||
// Scoped by user as well as by endpoint. It was endpoint alone, which meant
|
||||
// anybody who learned somebody else's endpoint could silently unsubscribe their
|
||||
// phone — and the endpoint is the only thing about a subscription that ever
|
||||
// leaves the browser.
|
||||
func (db *DB) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, endpoint, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// dropPushEndpoint removes a subscription the push service has declared dead.
|
||||
// Unexported so the only *request* path to a delete stays the scoped one.
|
||||
func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||
return err
|
||||
}
|
||||
@@ -186,7 +206,10 @@ func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error
|
||||
// silently unsubscribing somebody's phone.
|
||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||
if permanent {
|
||||
return db.DeletePushSubscription(ctx, endpoint)
|
||||
// Not scoped by user, and that is right here: this is the push service
|
||||
// saying the endpoint is gone, not a person asking for something. There is
|
||||
// no caller to check against — the caller is the delivery job.
|
||||
return db.dropPushEndpoint(ctx, endpoint)
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
|
||||
@@ -289,6 +312,11 @@ var secretFields = map[string]bool{
|
||||
"access_token": true,
|
||||
"password": true,
|
||||
"client_secret": true,
|
||||
// The AI provider's key. Missed when this list was written, and it is exactly
|
||||
// the same kind of thing as the rest: a live credential, in a settings blob,
|
||||
// in a database anybody with a backup file can read. Existing rows convert
|
||||
// themselves the next time the settings are saved.
|
||||
"api_key": true,
|
||||
}
|
||||
|
||||
// seal returns a copy of values with the secret fields sealed. Without a key it
|
||||
|
||||
@@ -20,6 +20,13 @@ func FoldDanish(s string) string {
|
||||
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.
|
||||
//
|
||||
@@ -51,6 +58,23 @@ func MatchExprOver(query string, columns ...string) string {
|
||||
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 {
|
||||
@@ -70,6 +94,11 @@ func MatchExprOver(query string, columns ...string) string {
|
||||
// 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)
|
||||
})
|
||||
|
||||
+20
-2
@@ -129,7 +129,14 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.TOTPSecret = secret.String
|
||||
// Opened on the way out. A row written before the key existed is returned as
|
||||
// itself — see secret.Unseal — so this does not need a migration; a secret
|
||||
// converts the next time it is written.
|
||||
if plain, err := db.unsealValue(secret.String); err == nil {
|
||||
u.TOTPSecret = plain
|
||||
} else {
|
||||
u.TOTPSecret = secret.String
|
||||
}
|
||||
u.TOTPEnabled = totpEnabled == 1
|
||||
u.IsAdmin = isAdmin == 1
|
||||
// A malformed value is read as "nothing folded" rather than as an error: the
|
||||
@@ -433,9 +440,20 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
||||
}
|
||||
|
||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||
// generate valid codes for every account on the instance — which turns the
|
||||
// second factor from a defence into a file somebody can take home.
|
||||
//
|
||||
// It was missed when internal/secret was written, along with three other
|
||||
// values; see SECURITY.md.
|
||||
var value any
|
||||
if secret != "" {
|
||||
value = secret
|
||||
sealed, err := db.sealValue(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value = sealed
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
|
||||
|
||||
Reference in New Issue
Block a user