Files
Aether/backend/internal/oidc/avatar_test.go
T
marcandClaude Opus 5 9f44215698 feat(oidc): Profilbild vom Identitätsanbieter übernehmen
Schickt der Anbieter ein `picture`, wird es bei der Anmeldung geholt,
mittig auf 256x256 zugeschnitten und oben rechts angezeigt. Fehlt es
oder scheitert das Holen, bleibt es beim Buchstabenkreis - die
Anmeldung selbst hängt nicht davon ab.

Die Bildadresse kommt aus einem Token, also von außen. Erlaubt ist
deshalb ausschließlich der Ursprung aus OIDC_ISSUER, Weiterleitungen
eingeschlossen; dazu 4 MB und 5 Sekunden als Deckel. Ohne das wäre es
eine Aufforderung an den Server, für einen Fremden beliebige Adressen
im internen Netz anzufragen.

Neu: Migration 000019 (avatar_key/-source/-updated_at), Paket
internal/avatar, Route GET /api/v1/users/{id}/avatar. Der GC durchsucht
"avatars/" mit und kennt die Schlüssel - sonst räumte er die Bilder
nach einem Tag weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:43:22 +02:00

140 lines
4.3 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 oidc
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"golang.org/x/oauth2"
"aether/internal/avatar"
)
// Die Bildadresse stammt aus einem Token, also von außen. Erlaubt ist genau
// der Ursprung des Ausstellers – alles andere wäre eine Aufforderung an den
// Server, für einen Fremden beliebige Adressen anzufragen.
func TestAllowedHost(t *testing.T) {
h := &Handler{cfg: Config{Issuer: "https://id.example.com"}}
erlaubt := []string{
"https://id.example.com/api/users/1/profile-picture.png",
"https://ID.EXAMPLE.COM/bild.png", // Groß-/Kleinschreibung zählt nicht
"https://id.example.com:443/bild.png",
}
for _, raw := range erlaubt {
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if err := h.allowedHost(u); err != nil {
t.Errorf("%s abgelehnt: %v", raw, err)
}
}
verboten := []string{
"http://169.254.169.254/latest/meta-data/", // Metadaten der Umgebung
"http://localhost:8080/api/v1/admin/users", // die eigene App
"http://postgres:5432/", // Nachbarcontainer
"https://id.example.com.angreifer.net/x.png", // Name nur vorne gleich
"https://id.example.com:9200/x.png", // gleicher Rechner, anderer Dienst
"file:///etc/passwd",
"gopher://id.example.com/",
}
for _, raw := range verboten {
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
if err := h.allowedHost(u); err == nil {
t.Errorf("%s wurde durchgelassen", raw)
}
}
}
// Ein Anbieter, der selbst auf einem Port läuft, muss zu sich selbst passen.
func TestAllowedHostMitPortImIssuer(t *testing.T) {
h := &Handler{cfg: Config{Issuer: "http://id.example.com:9000"}}
ok, _ := url.Parse("http://id.example.com:9000/bild.png")
if err := h.allowedHost(ok); err != nil {
t.Errorf("eigener Port abgelehnt: %v", err)
}
nein, _ := url.Parse("http://id.example.com/bild.png")
if err := h.allowedHost(nein); err == nil {
t.Error("Port 80 wurde für einen Anbieter auf 9000 durchgelassen")
}
}
// Der ganze Weg über das Netz – mit einem Anbieter, der erst 401 sagt und das
// Bild nur gegen Token herausgibt. Genau so verhalten sich Anbieter, die
// Profilbilder nicht öffentlich stellen.
func TestFetchAvatarHoltMitTokenNach(t *testing.T) {
var ohneToken, mitToken int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer geheim" {
ohneToken++
w.WriteHeader(http.StatusUnauthorized)
return
}
mitToken++
w.Write([]byte("PNG-Attrappe"))
}))
defer srv.Close()
h := &Handler{cfg: Config{Issuer: srv.URL}}
body, err := h.fetchAvatar(context.Background(), srv.URL+"/bild.png",
&oauth2.Token{AccessToken: "geheim"})
if err != nil {
t.Fatalf("fetchAvatar: %v", err)
}
if string(body) != "PNG-Attrappe" {
t.Errorf("Inhalt %q", body)
}
if ohneToken != 1 || mitToken != 1 {
t.Errorf("erwartet je ein Versuch, gemessen ohne=%d mit=%d", ohneToken, mitToken)
}
}
// Eine Weiterleitung darf den Anbieter nicht verlassen – sonst wäre die
// Prüfung der ersten Adresse wertlos.
func TestFetchAvatarFolgtKeinerFremdenWeiterleitung(t *testing.T) {
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("der fremde Server wurde angefragt")
}))
defer fremd.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fremd.URL+"/beute", http.StatusFound)
}))
defer srv.Close()
h := &Handler{cfg: Config{Issuer: srv.URL}}
if _, err := h.fetchAvatar(context.Background(), srv.URL+"/bild.png", nil); err == nil {
t.Fatal("Weiterleitung auf einen fremden Rechner wurde befolgt")
}
}
// Was der Anbieter schickt, ist unbegrenzt groß denkbar. Mehr als MaxBytes
// darf nie in den Speicher wandern.
func TestFetchAvatarBegrenztDieGroesse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1<<20)
for i := 0; i < 8; i++ {
if _, err := w.Write(buf); err != nil {
return
}
}
}))
defer srv.Close()
h := &Handler{cfg: Config{Issuer: srv.URL}}
body, err := h.fetchAvatar(context.Background(), srv.URL+"/riesig.png", nil)
if err != nil {
t.Fatalf("fetchAvatar: %v", err)
}
if len(body) > avatar.MaxBytes {
t.Errorf("%d Bytes gelesen, erlaubt sind %d", len(body), avatar.MaxBytes)
}
}