Files
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

311 lines
10 KiB
Go
Raw Permalink 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 sharing
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"aether/internal/acl"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/bg"
"aether/pkg/middleware"
"aether/pkg/response"
)
// Ein öffentlicher Endpunkt mit Passwort braucht dieselbe Bremse wie /dav
// und /caldav: Ohne sie kann man Passwörter unbegrenzt durchprobieren – und
// weil jeder Versuch Argon2 rechnet, ist das zugleich ein billiger Weg, den
// Server zu beschäftigen.
const (
shareMaxFailures = 10
shareWindow = 15 * time.Minute
shareLockout = 15 * time.Minute
// Zehn Jahre. Grosszügig gemeint – es geht nicht darum, kurze Fristen zu
// erzwingen, sondern den Überlauf abzufangen.
maxExpiryHours = 10 * 365 * 24
)
type Handler struct {
store *Store
fileStore *files.Store
storage storage.Storage
auditor *audit.Logger
// acl löst auf, in wessen Namen die Datei liegt. Ohne ihn ging nur, was
// dem Anfragenden persönlich gehört – siehe Create.
acl *acl.Store
limiter *middleware.FailureLimiter
}
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler {
return &Handler{
store: store, fileStore: fileStore, storage: st, auditor: auditor,
acl: aclStore,
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
}
}
// POST /api/v1/files/{id}/share
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
fileID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
// **Prinzipal statt Konto.** Vorher stand hier `user.ID` – damit liess sich
// eine Datei in einem Gruppenordner nicht per Link teilen, auch nicht von
// einem Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete
// „file not found", was zwar sicher, aber schlicht falsch war: Der
// Empfänger eines Links sieht ohnehin nur die Datei, nie den Ordner
// darum.
//
// **Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst,
// die Datei öffentlich zu machen. Wer sie nur geliehen lesen darf, soll
// sie nicht weiterreichen können.
zugriff, ok := h.resolve(w, r, fileID)
if !ok {
return
}
if _, err := h.fileStore.GetFile(r.Context(), fileID, zugriff.OwnerID); err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
var body struct {
Password string `json:"password"`
ExpiresIn *int `json:"expires_in_hours"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
var passwordHash string
if body.Password != "" {
passwordHash, err = auth.HashPassword(body.Password)
if err != nil {
response.Error(w, http.StatusInternalServerError, "hash error")
return
}
}
var expiresAt *time.Time
if body.ExpiresIn != nil && *body.ExpiresIn > 0 {
// Obergrenze, weil `time.Duration` in Nanosekunden rechnet: Ab etwa
// 2,6 Millionen Stunden läuft int64 über, die Dauer wird **negativ**,
// und der Link ist in dem Moment abgelaufen, in dem er entsteht – ohne
// Fehlermeldung. Wer eine unsinnige Zahl schickt, soll das erfahren,
// statt einen toten Link zu bekommen.
if *body.ExpiresIn > maxExpiryHours {
response.Error(w, http.StatusBadRequest,
"expires_in_hours is too large (at most 10 years)")
return
}
t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour)
expiresAt = &t
}
link, err := h.store.Create(r.Context(), fileID, user.ID, passwordHash, expiresAt)
if err != nil {
response.Error(w, http.StatusInternalServerError, "create share link failed")
return
}
// Ein Freigabelink macht eine Datei öffentlich erreichbar – gehört ins
// Protokoll. Der Token selbst nicht: er IST der Zugang.
detail := "no password"
if passwordHash != "" {
detail = "password protected"
}
if expiresAt != nil {
detail += ", expires " + expiresAt.Format(time.RFC3339)
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionShareCreate,
TargetType: "file",
TargetID: fileID.String(),
IP: middleware.RealIP(r),
Detail: detail,
})
response.JSON(w, http.StatusCreated, link)
}
// GET /api/v1/shares – list all share links owned by user
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
links, err := h.store.ListByOwner(r.Context(), user.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if links == nil {
links = []*ShareLink{}
}
response.JSON(w, http.StatusOK, links)
}
// DELETE /api/v1/shares/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid share id")
return
}
if err := h.store.Delete(r.Context(), id, user.ID); errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "share link not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
h.auditor.Log(r.Context(), audit.Event{
ActorID: &user.ID,
ActorName: user.Username,
Action: audit.ActionShareDelete,
TargetType: "share",
TargetID: id.String(),
IP: middleware.RealIP(r),
})
response.NoContent(w)
}
// GET /s/{token} – public share info (no auth required)
func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
link, _, err := h.store.GetByToken(r.Context(), token)
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) {
response.Error(w, http.StatusNotFound, "share link not found or expired")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "error")
return
}
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
// Nur, was die öffentliche Seite wirklich braucht. Das ganze
// Datenbankobjekt durchzureichen war bequem, gab aber owner_id und die
// internen Kennungen an jeden heraus, der den Link hat: Zwei Links
// desselben Besitzers ließen sich so einander zuordnen, ohne dass
// irgendwer angemeldet ist.
response.JSON(w, http.StatusOK, map[string]any{
"has_password": link.HasPassword,
"expires_at": link.ExpiresAt,
"download_count": link.DownloadCount,
"created_at": link.CreatedAt,
"file_name": f.Name,
"file_size": f.Size,
"mime_type": f.MimeType,
})
}
// POST /s/{token}/download – public download, optional password in body
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
link, passwordHash, err := h.store.GetByToken(r.Context(), token)
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) {
response.Error(w, http.StatusNotFound, "share link not found or expired")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "error")
return
}
if link.HasPassword {
clientIP := middleware.RealIP(r)
if erlaubt, retryAfter := h.limiter.Allowed(clientIP); !erlaubt {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1))
response.Error(w, http.StatusTooManyRequests, "too many failed attempts")
return
}
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
if !ok {
h.limiter.Fail(clientIP)
response.Error(w, http.StatusUnauthorized, "incorrect password")
return
}
h.limiter.Reset(clientIP)
}
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
rc, err := h.storage.Get(r.Context(), f.StorageKey)
if err != nil {
response.Error(w, http.StatusInternalServerError, "storage error")
return
}
defer rc.Close()
// context.WithoutCancel, nicht r.Context(): Der Request-Kontext wird
// abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das
// Rennen fast immer und der Zähler blieb auf 0 stehen.
zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
bg.Go("Freigabe-Zaehler", func() {
defer abbrechen()
h.store.IncrementDownloads(zaehlCtx, token)
})
w.Header().Set("Content-Type", f.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
// Kein Typ-Raten. `attachment` trägt die Hauptlast, aber der angemeldete
// Weg setzt beides – und dies ist der Weg nach draussen.
w.Header().Set("X-Content-Type-Options", "nosniff")
// **Dieselbe Prüfung wie beim angemeldeten Download.** Bis zum 25.09.2026
// lief hier ein blankes io.Copy: Wer die Datei über einen Link bekam,
// bekam sie ungeprüft. Ausgerechnet auf dem Weg, auf dem sie das Haus
// verlässt – und wo der Empfänger es am wenigsten merken kann.
files.ServeVerified(r.Context(), w, rc, f.Checksum, files.VerifyInfo{
FileID: f.ID.String(),
StorageKey: f.StorageKey,
Size: f.Size,
})
}
// resolve klärt, in wessen Namen die Datei liegt, und verlangt Schreibrecht.
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, fileID uuid.UUID) (acl.Access, bool) {
user := auth.UserFromContext(r.Context())
if h.acl == nil {
return acl.Access{OwnerID: user.ID, Permission: acl.PermWrite}, true
}
a, err := h.acl.ResolveFile(r.Context(), user.ID, fileID)
switch {
case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess):
// 404 statt 403, wie überall: Ein „verboten" verriete, dass es die ID
// gibt.
response.Error(w, http.StatusNotFound, "file not found")
return acl.Access{}, false
case err != nil:
response.Error(w, http.StatusInternalServerError, "database error")
return acl.Access{}, false
}
if !a.CanWrite() {
response.Error(w, http.StatusForbidden, "you only have read access to this file")
return acl.Access{}, false
}
return a, true
}