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>
140 lines
4.3 KiB
Go
140 lines
4.3 KiB
Go
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)
|
||
}
|
||
}
|