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>
This commit is contained in:
1 parent
1e1f35955d
commit
ec8f162fd5
5 files changed
+333
-11
No files matched your search
@@ -1,11 +1,13 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -19,15 +21,29 @@ import (
|
||||
"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}
|
||||
return &Handler{
|
||||
store: store, fileStore: fileStore, storage: st, auditor: auditor,
|
||||
limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/files/{id}/share
|
||||
@@ -147,11 +163,19 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
"link": link,
|
||||
"file_name": f.Name,
|
||||
"file_size": f.Size,
|
||||
"mime_type": f.MimeType,
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,15 +193,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -193,7 +225,14 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
go h.store.IncrementDownloads(r.Context(), token)
|
||||
// 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))
|
||||
|
||||
Reference in new issue
Block a user