Files
Aether/backend/internal/sharing/handler.go
T
marcandClaude Opus 5 ec8f162fd5 security(sharing): Selbsttest fuer Freigabelinks, drei Befunde behoben
Freigabelinks sind der einzige Teil von Aether, den man ohne Anmeldung
erreicht -- und hatten als einziger Bereich keinen einzigen Test. Der
neue scripts/sharing-selbsttest.py geht sie mit 24 Pruefungen ab, mit
einem eigens angelegten Zweitnutzer, ohne den sich "fremde Dateien
bleiben fremd" gar nicht pruefen laesst.

Beim ersten Lauf sofort drei Befunde:

1. Der Download-Zaehler blieb auf 0. Die Zaehlung lief in einer Goroutine
   mit dem Kontext der Anfrage -- der wird abgebrochen, sobald die
   Antwort draussen ist, und die Zaehlung verlor das Rennen fast immer.
   Jetzt context.WithoutCancel mit eigener Frist.

2. owner_id, file_id und die Link-Kennung standen in der oeffentlichen
   Antwort. Das ganze Datenbankobjekt durchzureichen war bequem, gab aber
   die Kennung des Besitzers an jeden heraus, der den Link hat: Zwei
   Links desselben Besitzers liessen sich einander zuordnen, ohne dass
   irgendwer angemeldet ist. Jetzt nur noch, was die oeffentliche Seite
   braucht.

3. Passwoerter liessen sich unbegrenzt durchprobieren. Der oeffentliche
   Freigabepfad lag ausserhalb jeder Ratenbegrenzung, und weil jeder
   Versuch Argon2 rechnet, war das zugleich ein billiger Weg, den Server
   zu beschaeftigen. Jetzt dieselbe Fehlversuchssperre wie bei /dav und
   /caldav: zehn Fehlschlaege in 15 Minuten, dann 15 Minuten Pause.

Die Sperre hat eine Folge fuers Testen selbst: Nach einem Lauf ist die
eigene IP 15 Minuten fuer passwortgeschuetzte Links gesperrt. Deshalb
steht die Probe am Ende, und der Test erkennt zu Beginn, ob eine Sperre
aus einem frueheren Lauf noch steht, statt reihenweise Fehlschlaege zu
melden, die keine sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:31:39 +02:00

242 lines
7.1 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 sharing
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"aether/internal/audit"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"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
)
type Handler struct {
store *Store
fileStore *files.Store
storage storage.Storage
auditor *audit.Logger
limiter *middleware.FailureLimiter
}
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
return &Handler{
store: store, fileStore: fileStore, storage: st, auditor: auditor,
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
}
if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); 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 {
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)
go 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))
_, _ = io.Copy(w, rc)
}