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>
This commit is contained in:
1 parent
a7528d7365
commit
6fab2388ca
9 files changed
+628
-74
No files matched your search
@@ -1,8 +1,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -500,70 +498,11 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
|
||||
// Verbindung noch fallen lassen, und der Browser sieht einen
|
||||
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
|
||||
h256 := sha256.New()
|
||||
zurueck := &tailWriter{w: w, keep: verifyTail}
|
||||
|
||||
if _, err := io.Copy(zurueck, io.TeeReader(rc, h256)); err != nil {
|
||||
return // Gegenstelle ist weg
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(h256.Sum(nil))
|
||||
if actual != f.Checksum {
|
||||
log.Ctx(r.Context()).Error().
|
||||
Str("file_id", f.ID.String()).
|
||||
Str("storage_key", f.StorageKey).
|
||||
Str("erwartet", f.Checksum).
|
||||
Str("gelesen", actual).
|
||||
Int64("size", f.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). Weil der Schluss noch nicht draußen ist,
|
||||
// bekommt die Gegenstelle weniger Bytes als im Content-Length
|
||||
// angekündigt – und wertet das als Fehler.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// Stimmt sie, darf der Rest hinterher.
|
||||
_ = zurueck.Flush()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
ServeVerified(r.Context(), w, rc, f.Checksum, VerifyInfo{
|
||||
FileID: f.ID.String(),
|
||||
StorageKey: f.StorageKey,
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.
|
||||
|
||||
Reference in new issue
Block a user