Compare commits
2
Commits
1e1f35955d
...
fce8053d09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce8053d09 | ||
|
|
ec8f162fd5 |
No files matched your search
+40
@@ -95,6 +95,46 @@ Dazu eine Ungenauigkeit ohne Folgen: Ein PUT, das einen vorhandenen Termin
|
|||||||
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
|
überschreibt, antwortet mit `201 Created` statt `204 No Content`. Das kommt
|
||||||
aus go-webdav; Clients prüfen auf 2xx.
|
aus go-webdav; Clients prüfen auf 2xx.
|
||||||
|
|
||||||
|
## Freigabelinks: Selbsttest per Skript
|
||||||
|
|
||||||
|
Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung
|
||||||
|
erreicht. Ein Fehler darin ist kein Anzeigefehler, sondern eine offene Tür.
|
||||||
|
|
||||||
|
python3 scripts/sharing-selbsttest.py
|
||||||
|
|
||||||
|
24 Prüfungen: anlegen nur angemeldet und nur für **eigene** Dateien (auch der
|
||||||
|
Admin kommt nicht an fremde), Tokenlänge, öffentlicher Abruf ohne Anmeldung,
|
||||||
|
Herunterladen byte-genau, Passwortschutz (ohne, falsch, richtig),
|
||||||
|
Download-Zähler, Widerrufen (danach ist der Link tot), ein Link auf eine
|
||||||
|
gelöschte Datei liefert nichts, und die Sperre nach wiederholten
|
||||||
|
Fehlversuchen.
|
||||||
|
|
||||||
|
Der Test legt einen Zweitnutzer an – ohne ihn lässt sich „fremde Dateien
|
||||||
|
bleiben fremd" gar nicht prüfen – und räumt ihn samt Dateien wieder weg.
|
||||||
|
|
||||||
|
**Nach einem Lauf ist die eigene IP 15 Minuten gesperrt**, für
|
||||||
|
passwortgeschützte Freigabelinks (nicht fürs Anmelden). Das ist der Preis der
|
||||||
|
letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb
|
||||||
|
der Frist bricht früh ab und sagt es. Beim Entwickeln hilft ein
|
||||||
|
`docker restart aether-app` – die Sperre liegt im Arbeitsspeicher.
|
||||||
|
|
||||||
|
### Was dieser Test beim ersten Lauf gefunden hat
|
||||||
|
|
||||||
|
Drei Dinge, alle behoben – hier notiert, weil sie zeigen, wonach man in
|
||||||
|
diesem Bereich sucht:
|
||||||
|
|
||||||
|
- **Der Download-Zähler blieb auf 0.** Die Zählung lief in einer Goroutine
|
||||||
|
mit dem Kontext der Anfrage – der wird abgebrochen, sobald die Antwort
|
||||||
|
draußen ist, und die Zählung verlor das Rennen fast immer.
|
||||||
|
- **`owner_id` stand in der öffentlichen Antwort.** Das ganze
|
||||||
|
Datenbankobjekt durchzureichen war bequem, gab aber die Kennung des
|
||||||
|
Besitzers an jeden heraus, der den Link hat: Zwei Links desselben
|
||||||
|
Besitzers ließen sich einander zuordnen, ohne dass irgendwer angemeldet
|
||||||
|
ist.
|
||||||
|
- **Passwörter ließen sich unbegrenzt durchprobieren.** Der öffentliche
|
||||||
|
Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet
|
||||||
|
Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen.
|
||||||
|
|
||||||
## Notfall: Migration hängt fest ("Dirty database version")
|
## Notfall: Migration hängt fest ("Dirty database version")
|
||||||
|
|
||||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func spaHandler(root string) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil {
|
||||||
|
setStaticCaching(w, upath)
|
||||||
fs.ServeHTTP(w, r)
|
fs.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,10 +82,37 @@ func spaHandler(root string) http.Handler {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setStaticCaching(w, "/index.html")
|
||||||
http.ServeFile(w, r, index)
|
http.ServeFile(w, r, index)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setStaticCaching entscheidet, was der Browser behalten darf.
|
||||||
|
//
|
||||||
|
// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control,
|
||||||
|
// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt.
|
||||||
|
// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die
|
||||||
|
// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem
|
||||||
|
// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern
|
||||||
|
// als rätselhafter Fehler mitten in der Anwendung.
|
||||||
|
//
|
||||||
|
// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst:
|
||||||
|
//
|
||||||
|
// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt,
|
||||||
|
// ändert sich der Name. Solche Dateien darf der Browser für immer
|
||||||
|
// behalten – "immutable" spart ihm sogar die Rückfrage.
|
||||||
|
// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei
|
||||||
|
// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht
|
||||||
|
// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet
|
||||||
|
// der Server dann meist nur mit 304 und ein paar Byte.
|
||||||
|
func setStaticCaching(w http.ResponseWriter, upath string) {
|
||||||
|
if strings.HasPrefix(upath, "/assets/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package sharing
|
package sharing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -19,15 +21,29 @@ import (
|
|||||||
"aether/pkg/response"
|
"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 {
|
type Handler struct {
|
||||||
store *Store
|
store *Store
|
||||||
fileStore *files.Store
|
fileStore *files.Store
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
auditor *audit.Logger
|
auditor *audit.Logger
|
||||||
|
limiter *middleware.FailureLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
|
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
|
// POST /api/v1/files/{id}/share
|
||||||
@@ -147,8 +163,16 @@ func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusNotFound, "file not found")
|
response.Error(w, http.StatusNotFound, "file not found")
|
||||||
return
|
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{
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
"link": link,
|
"has_password": link.HasPassword,
|
||||||
|
"expires_at": link.ExpiresAt,
|
||||||
|
"download_count": link.DownloadCount,
|
||||||
|
"created_at": link.CreatedAt,
|
||||||
"file_name": f.Name,
|
"file_name": f.Name,
|
||||||
"file_size": f.Size,
|
"file_size": f.Size,
|
||||||
"mime_type": f.MimeType,
|
"mime_type": f.MimeType,
|
||||||
@@ -169,15 +193,23 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if link.HasPassword {
|
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 {
|
var body struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
h.limiter.Fail(clientIP)
|
||||||
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
response.Error(w, http.StatusUnauthorized, "incorrect password")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.limiter.Reset(clientIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := h.fileStore.GetFileByID(r.Context(), link.FileID)
|
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()
|
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-Type", f.MimeType)
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||||
|
|||||||
@@ -90,8 +90,14 @@ export interface CreateShareRequest {
|
|||||||
expires_in_hours?: number
|
expires_in_hours?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was ein Freigabelink **ohne Anmeldung** über sich preisgibt. Bewusst
|
||||||
|
* nicht der ganze ShareLink: Besitzer und interne Kennungen gehen niemanden
|
||||||
|
* etwas an, der bloß den Link hat. */
|
||||||
export interface PublicShareInfo {
|
export interface PublicShareInfo {
|
||||||
link: ShareLink
|
has_password: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
download_count: number
|
||||||
|
created_at: string
|
||||||
file_name: string
|
file_name: string
|
||||||
file_size: number
|
file_size: number
|
||||||
mime_type: string
|
mime_type: string
|
||||||
|
|||||||
@@ -5,6 +5,32 @@ import App from './App'
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sicherheitsnetz für den Fall, dass der Browser noch eine alte Fassung der
|
||||||
|
* Anwendung im Speicher hat und einen Baustein nachlädt, den es auf dem
|
||||||
|
* Server nicht mehr gibt.
|
||||||
|
*
|
||||||
|
* Die Bausteine tragen einen Hash im Namen; nach einem Update sind die alten
|
||||||
|
* fort. Wer die Seite offen liegen lässt und dann auf einen Bereich klickt,
|
||||||
|
* der erst dann geladen wird (der Kalender, der PDF-Betrachter), bekommt sonst
|
||||||
|
* einen Fehler, der nach einem Programmfehler aussieht – oder es passiert
|
||||||
|
* schlicht nichts.
|
||||||
|
*
|
||||||
|
* Einmal neu laden holt die neue Fassung. Das `sessionStorage`-Merkmal
|
||||||
|
* verhindert dabei das Schlimmere: Läge der Fehler doch woanders, drehte sich
|
||||||
|
* die Seite sonst endlos im Kreis.
|
||||||
|
*/
|
||||||
|
window.addEventListener('vite:preloadError', () => {
|
||||||
|
if (sessionStorage.getItem('aether-reloaded-for-update')) return
|
||||||
|
sessionStorage.setItem('aether-reloaded-for-update', '1')
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
// Erfolgreich geladen: Die Sperre darf wieder fallen, sonst hilft sie beim
|
||||||
|
// nächsten Update nicht mehr.
|
||||||
|
setTimeout(() => sessionStorage.removeItem('aether-reloaded-for-update'), 5000)
|
||||||
|
})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function SharePage({ token }: Props) {
|
|||||||
sharesApi.getInfo(token)
|
sharesApi.getInfo(token)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setInfo(data)
|
setInfo(data)
|
||||||
setNeedsPassword(data.link.has_password)
|
setNeedsPassword(data.has_password)
|
||||||
})
|
})
|
||||||
.catch(() => setError('This link is not valid or has expired.'))
|
.catch(() => setError('This link is not valid or has expired.'))
|
||||||
}, [token])
|
}, [token])
|
||||||
@@ -118,8 +118,8 @@ export default function SharePage({ token }: Props) {
|
|||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
<p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
{formatBytes(info.file_size)}
|
{formatBytes(info.file_size)}
|
||||||
{info.link.expires_at && (
|
{info.expires_at && (
|
||||||
<span> · Expires {new Date(info.link.expires_at).toLocaleDateString()}</span>
|
<span> · Expires {new Date(info.expires_at).toLocaleDateString()}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export default function SharePage({ token }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style={{ margin: '16px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
<p style={{ margin: '16px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||||
Shared via Aether · Downloaded {info.link.download_count}×
|
Shared via Aether · Downloaded {info.download_count}×
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Selbsttest der öffentlichen Freigabelinks.
|
||||||
|
|
||||||
|
python3 scripts/sharing-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
|
||||||
|
|
||||||
|
Freigabelinks sind der **einzige** Teil von Aether, den man ohne Anmeldung
|
||||||
|
erreicht. Ein Fehler darin ist deshalb kein Anzeigefehler, sondern eine
|
||||||
|
offene Tür – und genau deshalb gibt es diesen Test.
|
||||||
|
|
||||||
|
Er legt einen Zweitnutzer, einen Ordner und Testdateien an und räumt am Ende
|
||||||
|
alles wieder weg. Vorhandene Dateien werden nicht angefasst.
|
||||||
|
|
||||||
|
**Nach einem Lauf ist die eigene IP 15 Minuten gesperrt** – für
|
||||||
|
passwortgeschützte Freigabelinks, nicht fürs Anmelden. Das ist der Preis der
|
||||||
|
letzten Prüfung, die genau diese Sperre nachweist. Ein zweiter Lauf innerhalb
|
||||||
|
der Frist bricht deshalb früh ab und sagt es.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
|
||||||
|
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
|
||||||
|
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
|
||||||
|
|
||||||
|
ok_count = 0
|
||||||
|
fail = []
|
||||||
|
hinweise = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
global ok_count
|
||||||
|
if cond:
|
||||||
|
ok_count += 1
|
||||||
|
print(f" \033[32mok\033[0m {name}")
|
||||||
|
else:
|
||||||
|
fail.append(name)
|
||||||
|
print(f" \033[31mFEHL\033[0m {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def hinweis(text):
|
||||||
|
hinweise.append(text)
|
||||||
|
print(f" \033[33m!\033[0m {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def sitzung():
|
||||||
|
"""Eigener Cookie-Topf je Nutzer – sonst überschreibt die zweite
|
||||||
|
Anmeldung die erste, und der Test prüft am Ende sich selbst."""
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
|
||||||
|
|
||||||
|
def ruf(opener, method, path, body=None, headers=None, roh=False):
|
||||||
|
hdrs = dict(headers or {})
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
|
||||||
|
try:
|
||||||
|
with (opener or urllib.request.build_opener()).open(req) as r:
|
||||||
|
payload = r.read()
|
||||||
|
if roh:
|
||||||
|
return r.status, payload
|
||||||
|
text = payload.decode()
|
||||||
|
return r.status, (json.loads(text).get("data") if text else None)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, None
|
||||||
|
|
||||||
|
|
||||||
|
def anmelden(user, passwort):
|
||||||
|
o = sitzung()
|
||||||
|
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
|
||||||
|
assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}"
|
||||||
|
return o, {"X-CSRF-Token": d["csrf_token"]}
|
||||||
|
|
||||||
|
|
||||||
|
def hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
|
||||||
|
"""Multipart von Hand – der Upload nimmt kein JSON."""
|
||||||
|
grenze = "----aether" + uuid.uuid4().hex
|
||||||
|
teile = []
|
||||||
|
if folder_id:
|
||||||
|
teile.append(f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n')
|
||||||
|
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
||||||
|
f'Content-Type: application/octet-stream\r\n\r\n')
|
||||||
|
leib = b"".join(t.encode() for t in teile) + kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode()
|
||||||
|
req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST",
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={grenze}",
|
||||||
|
**csrf})
|
||||||
|
with opener.open(req) as r:
|
||||||
|
return json.loads(r.read().decode())["data"]
|
||||||
|
|
||||||
|
|
||||||
|
print(f"Freigabe-Selbsttest gegen {BASE}\n")
|
||||||
|
|
||||||
|
admin, ADMIN_CSRF = anmelden(USER, PASSWORT)
|
||||||
|
|
||||||
|
# ── Zweitnutzer: nur mit ihm lässt sich prüfen, dass fremde Dateien fremd
|
||||||
|
# bleiben. Ein Test, der nur das eigene Konto kennt, kann das nicht.
|
||||||
|
fremd_name = "selbsttest-fremd"
|
||||||
|
fremd_pw = "Fremd!" + uuid.uuid4().hex[:12]
|
||||||
|
st, fremd = ruf(admin, "POST", "/api/v1/admin/users",
|
||||||
|
{"username": fremd_name, "display_name": "Selbsttest Fremd",
|
||||||
|
"password": fremd_pw, "is_admin": False, "quota_bytes": 0}, ADMIN_CSRF)
|
||||||
|
assert st in (200, 201), f"Zweitnutzer anlegen fehlgeschlagen: {st}"
|
||||||
|
FREMD_ID = fremd["id"]
|
||||||
|
|
||||||
|
eigen = None
|
||||||
|
fremd_datei = None
|
||||||
|
links = []
|
||||||
|
|
||||||
|
|
||||||
|
def aufraeumen():
|
||||||
|
for lid in links:
|
||||||
|
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, ADMIN_CSRF)
|
||||||
|
if eigen:
|
||||||
|
ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF)
|
||||||
|
ruf(admin, "DELETE", f"/api/v1/admin/users/{FREMD_ID}", None, ADMIN_CSRF)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
INHALT = b"streng geheim " + uuid.uuid4().hex.encode()
|
||||||
|
eigen = hochladen(admin, ADMIN_CSRF, "selbsttest-freigabe.txt", INHALT)
|
||||||
|
|
||||||
|
fremd_op, FREMD_CSRF = anmelden(fremd_name, fremd_pw)
|
||||||
|
fremd_datei = hochladen(fremd_op, FREMD_CSRF, "selbsttest-fremd.txt", b"gehoert mir")
|
||||||
|
|
||||||
|
print("── Anlegen: nur eigene Dateien, nur angemeldet ──")
|
||||||
|
st, _ = ruf(None, "POST", f"/api/v1/files/{eigen['id']}/share", {})
|
||||||
|
check("ohne Anmeldung: kein Link", st in (401, 403), st)
|
||||||
|
|
||||||
|
# Der Kern: Eine fremde Datei darf sich nicht freigeben lassen – sonst
|
||||||
|
# macht ein beliebiger Nutzer fremde Daten öffentlich.
|
||||||
|
st, _ = ruf(fremd_op, "POST", f"/api/v1/files/{eigen['id']}/share", {}, FREMD_CSRF)
|
||||||
|
check("fremde Datei freigeben: 404", st == 404, st)
|
||||||
|
st, _ = ruf(admin, "POST", f"/api/v1/files/{fremd_datei['id']}/share", {}, ADMIN_CSRF)
|
||||||
|
check("auch als Admin nicht: 404", st == 404, st)
|
||||||
|
|
||||||
|
st, link = ruf(admin, "POST", f"/api/v1/files/{eigen['id']}/share", {}, ADMIN_CSRF)
|
||||||
|
check("eigene Datei freigeben (201)", st == 201, st)
|
||||||
|
links.append(link["id"])
|
||||||
|
TOKEN = link["token"]
|
||||||
|
|
||||||
|
# 24 zufällige Bytes, base64url = 32 Zeichen. Raten ist damit aussichtslos;
|
||||||
|
# ein kurzer Token wäre die stille Version einer offenen Tür.
|
||||||
|
check("Token ist lang genug (>= 32 Zeichen)", len(TOKEN) >= 32, len(TOKEN))
|
||||||
|
|
||||||
|
print("\n── Öffentlich abrufen ──")
|
||||||
|
st, info = ruf(None, "GET", f"/share/{TOKEN}")
|
||||||
|
check("Info ohne Anmeldung abrufbar", st == 200, st)
|
||||||
|
check("nennt Namen und Größe",
|
||||||
|
info and info.get("file_name") == "selbsttest-freigabe.txt"
|
||||||
|
and info.get("file_size") == len(INHALT), info)
|
||||||
|
# Was der Server öffentlich preisgibt, muss man einmal ausdrücklich prüfen:
|
||||||
|
# Ein durchgereichtes Datenbankfeld zu viel, und der Hash steht im Netz.
|
||||||
|
flach = json.dumps(info).lower()
|
||||||
|
check("kein Passwort-Hash in der Antwort",
|
||||||
|
"password_hash" not in flach and "$argon2" not in flach, flach[:200])
|
||||||
|
check("keine Angabe zum Besitzer", "owner_id" not in flach, flach[:200])
|
||||||
|
|
||||||
|
st, _ = ruf(None, "GET", "/share/" + "x" * 32)
|
||||||
|
check("erfundener Token: 404", st == 404, st)
|
||||||
|
|
||||||
|
st, daten = ruf(None, "POST", f"/share/{TOKEN}/download", {}, roh=True)
|
||||||
|
check("Herunterladen ohne Passwort (200)", st == 200, st)
|
||||||
|
check("Inhalt stimmt byte-genau", daten == INHALT, daten[:40])
|
||||||
|
|
||||||
|
st, info = ruf(None, "GET", f"/share/{TOKEN}")
|
||||||
|
check("Zähler steht auf 1", info and info["download_count"] == 1,
|
||||||
|
info["download_count"] if info else None)
|
||||||
|
|
||||||
|
print("\n── Mit Passwort ──")
|
||||||
|
st, geschuetzt = ruf(admin, "POST", f"/api/v1/files/{eigen['id']}/share",
|
||||||
|
{"password": "geheim123"}, ADMIN_CSRF)
|
||||||
|
links.append(geschuetzt["id"])
|
||||||
|
GTOKEN = geschuetzt["token"]
|
||||||
|
check("Link mit Passwort angelegt", st == 201 and geschuetzt["has_password"] is True, st)
|
||||||
|
|
||||||
|
st, _ = ruf(None, "POST", f"/share/{GTOKEN}/download", {})
|
||||||
|
if st == 429:
|
||||||
|
# Die Sperre aus einem Lauf vor weniger als 15 Minuten steht noch.
|
||||||
|
# Das ist richtiges Verhalten des Servers, aber der Rest dieses
|
||||||
|
# Abschnitts kann so nichts mehr aussagen.
|
||||||
|
hinweis("IP ist noch gesperrt (Lauf vor weniger als 15 Minuten) – "
|
||||||
|
"Passwortprüfungen übersprungen, bitte später erneut laufen lassen")
|
||||||
|
raise SystemExit(1 if fail else 0)
|
||||||
|
check("ohne Passwort: 401", st == 401, st)
|
||||||
|
st, _ = ruf(None, "POST", f"/share/{GTOKEN}/download", {"password": "falsch"})
|
||||||
|
check("falsches Passwort: 401", st == 401, st)
|
||||||
|
st, daten = ruf(None, "POST", f"/share/{GTOKEN}/download", {"password": "geheim123"}, roh=True)
|
||||||
|
check("richtiges Passwort: Inhalt", st == 200 and daten == INHALT, st)
|
||||||
|
|
||||||
|
print("\n── Widerrufen ──")
|
||||||
|
st, _ = ruf(fremd_op, "DELETE", f"/api/v1/shares/{link['id']}", None, FREMD_CSRF)
|
||||||
|
check("fremder Link lässt sich nicht löschen", st == 404, st)
|
||||||
|
|
||||||
|
st, _ = ruf(admin, "DELETE", f"/api/v1/shares/{link['id']}", None, ADMIN_CSRF)
|
||||||
|
check("eigener Link gelöscht", st in (200, 204), st)
|
||||||
|
links.remove(link["id"])
|
||||||
|
st, _ = ruf(None, "GET", f"/share/{TOKEN}")
|
||||||
|
check("danach ist der Link tot (404)", st == 404, st)
|
||||||
|
st, _ = ruf(None, "POST", f"/share/{TOKEN}/download", {})
|
||||||
|
check("und das Herunterladen auch", st == 404, st)
|
||||||
|
|
||||||
|
print("\n── Gelöschte Datei ──")
|
||||||
|
st, fluechtig = ruf(admin, "POST", f"/api/v1/files/{eigen['id']}/share", {}, ADMIN_CSRF)
|
||||||
|
links.append(fluechtig["id"])
|
||||||
|
ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF)
|
||||||
|
# In den Papierkorb geworfen heißt: für die Öffentlichkeit weg. Ein Link,
|
||||||
|
# der weiterläuft, während die Datei im Papierkorb liegt, wäre eine
|
||||||
|
# Freigabe, von der der Besitzer nichts mehr weiß.
|
||||||
|
st, _ = ruf(None, "POST", f"/share/{fluechtig['token']}/download", {})
|
||||||
|
check("Link auf gelöschte Datei liefert nichts", st in (404, 410), st)
|
||||||
|
|
||||||
|
# Zum Schluss, mit Absicht: Diese Probe sperrt die eigene IP für 15
|
||||||
|
# Minuten. Stünde sie weiter oben, fielen alle folgenden Passwortprüfungen
|
||||||
|
# in die selbst gestellte Falle.
|
||||||
|
print("\n── Durchprobieren von Passwörtern ──")
|
||||||
|
codes = [ruf(None, "POST", f"/share/{GTOKEN}/download", {"password": f"f{i}"})[0]
|
||||||
|
for i in range(15)]
|
||||||
|
check("Sperre greift nach wiederholten Fehlversuchen (429)", 429 in codes,
|
||||||
|
f"letzte Antworten: {codes[-5:]}")
|
||||||
|
check("und sie greift erst nach mehreren Versuchen", codes[0] == 401, codes[:3])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
aufraeumen()
|
||||||
|
|
||||||
|
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
|
||||||
|
for h in hinweise:
|
||||||
|
print(f"Hinweis: {h}")
|
||||||
|
if fail:
|
||||||
|
print("Fehlgeschlagen: " + ", ".join(fail))
|
||||||
|
sys.exit(1 if fail else 0)
|
||||||
Reference in new issue
Block a user