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:
marcandClaude Opus 5 committed 2026-09-06 23:31:39 +02:00
1 parent 1e1f35955d
commit ec8f162fd5
5 files changed
+333 -11

No files matched your search

+7 -1
View File
@@ -90,8 +90,14 @@ export interface CreateShareRequest {
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 {
link: ShareLink
has_password: boolean
expires_at?: string | null
download_count: number
created_at: string
file_name: string
file_size: number
mime_type: string
+4 -4
View File
@@ -24,7 +24,7 @@ export default function SharePage({ token }: Props) {
sharesApi.getInfo(token)
.then(data => {
setInfo(data)
setNeedsPassword(data.link.has_password)
setNeedsPassword(data.has_password)
})
.catch(() => setError('This link is not valid or has expired.'))
}, [token])
@@ -118,8 +118,8 @@ export default function SharePage({ token }: Props) {
</h2>
<p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}>
{formatBytes(info.file_size)}
{info.link.expires_at && (
<span> · Expires {new Date(info.link.expires_at).toLocaleDateString()}</span>
{info.expires_at && (
<span> · Expires {new Date(info.expires_at).toLocaleDateString()}</span>
)}
</p>
@@ -167,7 +167,7 @@ export default function SharePage({ token }: Props) {
</button>
<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>
</>
)}