Files
Aether/backend/internal/files/verified.go
T
marcandClaude Opus 5 6fab2388ca security(sharing): Freigabelink prüft die Prüfsumme, Gruppenordner teilbar
Beim Lesen von internal/sharing fiel eine ungleiche Zusage auf: Der
angemeldete Download prüft seit dem 12.09. die SHA-256-Summe beim Ausliefern
und lässt die Verbindung fallen, wenn sie nicht stimmt. Der öffentliche
Freigabelink lief durch ein blankes io.Copy – ungeprüft.

Ausgerechnet der Weg nach draussen. Wer die Datei angemeldet holt, ist der
Eigentümer und merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es
nicht. In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload
berechnen, beim Download verifizieren. Die Regel galt an einer von zwei
Stellen. Die Mechanik liegt jetzt in files/verified.go und wird von beiden
gerufen. Nachgewiesen mit gekipptem Bit auf der Platte: Über den Freigabelink
kommt die Datei ebenfalls nicht mehr vollständig an.

Gruppenordner: Create übergab user.ID statt des aufgelösten Prinzipals – eine
Datei im Gruppenordner liess sich nicht verlinken, auch nicht vom Mitglied.
Marcs Entscheidung: Das soll gehen, der Empfänger sieht ohnehin nur die Datei,
nie den Ordner darum. Verlangt wird Schreibrecht: Einen Link zu erzeugen
heisst, die Datei öffentlich zu machen; wer sie nur geliehen lesen darf, soll
sie nicht weiterreichen können (403).

expires_in_hours hatte keine Obergrenze. time.Duration rechnet in
Nanosekunden; ab etwa 2,6 Mio Stunden läuft int64 über, die Dauer wird negativ,
und der Link ist abgelaufen in dem Moment, in dem er entsteht – ohne
Fehlermeldung. Jetzt zehn Jahre, darüber 400.

Dazu X-Content-Type-Options: nosniff, das der angemeldete Weg längst setzt.

Sechs Go-Tests für internal/sharing, unter Mutation nachgewiesen:
Eigentümerprüfung entfernt, Ablaufprüfung entfernt und Token verkürzt machen
jeweils genau den zugehörigen Test rot.

Selbsttests erweitert: sharing 24 → 31 Prüfungen, pruefsumme 14 → 16.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 241 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:57:10 +02:00

103 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package files
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"github.com/rs/zerolog/log"
)
// Ausliefern mit Prüfung der SHA-256-Summe.
//
// Stand bis zum 25.09.2026 nur im angemeldeten Download. Der **öffentliche**
// Freigabelink lieferte ungeprüft aus – ausgerechnet der Weg, auf dem eine
// Datei das Haus verlässt. Wer sie angemeldet holt, ist der Eigentümer und
// merkt, wenn etwas kaputt ist; wer einen Link bekommt, merkt es nicht.
//
// In CLAUDE.md steht das unter „nicht verhandelbar": beim Upload berechnen,
// **beim Download verifizieren**. Die Regel galt an einer von zwei Stellen.
// Deshalb liegt sie jetzt hier und wird von beiden gerufen.
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
//
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
const verifyTail = 32 * 1024
// VerifyInfo ist nur für den Logeintrag – wer hinterher sucht, braucht die
// Datei und ihren Platz auf der Platte, nicht bloß „stimmt nicht".
type VerifyInfo struct {
FileID string
StorageKey string
Size int64
}
// ServeVerified kopiert `src` nach `w` und prüft dabei die Summe.
//
// Stimmt sie nicht, wird der letzte Rest **nicht** herausgegeben und die
// Verbindung fallen gelassen. Die Kopfzeilen sind da längst draußen, der
// Statuscode also nicht mehr zu ändern – aber die Gegenstelle bekommt weniger
// Bytes als im Content-Length angekündigt und wertet das als Fehler. Besser
// ein abgebrochener Download als eine heile Datei mit falschem Inhalt.
func ServeVerified(ctx context.Context, w io.Writer, src io.Reader, erwartet string, info VerifyInfo) {
h256 := sha256.New()
zurueck := &tailWriter{w: w, keep: verifyTail}
if _, err := io.Copy(zurueck, io.TeeReader(src, h256)); err != nil {
return // Gegenstelle ist weg
}
gelesen := hex.EncodeToString(h256.Sum(nil))
if gelesen != erwartet {
log.Ctx(ctx).Error().
Str("file_id", info.FileID).
Str("storage_key", info.StorageKey).
Str("erwartet", erwartet).
Str("gelesen", gelesen).
Int64("size", info.Size).
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
// fallen zu lassen: net/http schließt die Verbindung und schreibt
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
// Panik ausdrücklich durch).
panic(http.ErrAbortHandler)
}
// Stimmt sie, darf der Rest hinterher.
_ = zurueck.Flush()
}
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
type tailWriter struct {
w io.Writer
keep int
buf []byte
}
func (t *tailWriter) Write(p []byte) (int, error) {
t.buf = append(t.buf, p...)
if ueber := len(t.buf) - t.keep; ueber > 0 {
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
return 0, err
}
t.buf = append(t.buf[:0], t.buf[ueber:]...)
}
return len(p), nil
}
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
// bleibt die Antwort unvollständig – genau das ist der Zweck.
func (t *tailWriter) Flush() error {
if len(t.buf) == 0 {
return nil
}
_, err := t.w.Write(t.buf)
t.buf = t.buf[:0]
return err
}