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:
marcandClaude Opus 5 committed 2026-09-25 17:57:10 +02:00
1 parent a7528d7365
commit 6fab2388ca
9 files changed
+628 -74

No files matched your search

+5 -66
View File
@@ -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.
+102
View File
@@ -0,0 +1,102 @@
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
}