diff --git a/archive/cmd/retention-api/main.go b/archive/cmd/retention-api/main.go index db13474..e0f99fc 100644 --- a/archive/cmd/retention-api/main.go +++ b/archive/cmd/retention-api/main.go @@ -34,6 +34,11 @@ func main() { if rbacServiceToken == "" { log.Fatal("NEXARCH_RETENTION_RBAC_SERVICE_TOKEN muss gesetzt sein") } + // RET-10: erlaubter Frontend-Origin fuer CORS, KEIN Wildcard. + corsOrigin := os.Getenv("NEXARCH_RETENTION_CORS_ALLOWED_ORIGIN") + if corsOrigin == "" { + log.Fatal("NEXARCH_RETENTION_CORS_ALLOWED_ORIGIN muss gesetzt sein (Origin des RET-06-Frontends)") + } ctx := context.Background() pool, err := pgxpool.New(ctx, dsn) @@ -48,8 +53,10 @@ func main() { retentionapi.Mount(mux, pool, rbac) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - log.Printf("retention-api: listening on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + handler := retentionapi.CORS(corsOrigin, mux) + + log.Printf("retention-api: listening on %s (cors origin: %s)", addr, corsOrigin) + if err := http.ListenAndServe(addr, handler); err != nil { log.Fatalf("http server: %v", err) } } diff --git a/archive/docs/RET-10-PRUEFPROTOKOLL.md b/archive/docs/RET-10-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..846158c --- /dev/null +++ b/archive/docs/RET-10-PRUEFPROTOKOLL.md @@ -0,0 +1,62 @@ +# RET-10 – Prüfprotokoll: CORS für RET-06-API (Frontend-Zugriff aus dem Browser) + +Voraussetzung RET-06-API – bereits Fertig, hier NICHT sonst verändert +(nur `cmd/retention-api/main.go` um die Middleware-Verdrahtung ergänzt). + +## Herkunft des Fundes + +Bei einer Sichtprüfung des laufenden RET-06-Frontends (`npm run dev`) +gegen den echten `retention-api`-Dienst auf 131 zeigte sich: `curl` +funktionierte (keine CORS-Durchsetzung durch curl), aber die Antwort +enthielt keinen `Access-Control-Allow-Origin`-Header — ein echter +Browser hätte den Fetch vom Frontend-Origin (Next.js-Dev-Server) zum +Backend-Origin (retention-api) blockiert. Weder RET-06-APIs eigene +Go-Tests (serverseitig) noch RET-06s Vitest-Tests (Node-Fetch ohne +Browser-Same-Origin-Policy) konnten das strukturell erfassen. + +## Umsetzung + +- `archive/internal/retentionapi/cors.go` – `CORS(allowedOrigin string, next http.Handler) http.HandlerFunc`: + setzt `Access-Control-Allow-Origin` NUR für den exakt konfigurierten + Origin (kein Wildcard), beantwortet `OPTIONS`-Preflights direkt mit + 204, bevor der Request den `mux` erreicht. +- `archive/cmd/retention-api/main.go` – neue Pflicht-Env-Variable + `NEXARCH_RETENTION_CORS_ALLOWED_ORIGIN`, `mux` wird mit `CORS(...)` + umschlossen. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Realer Browser-Fetch (nicht curl) gelingt, Same-Origin-Policy nicht mehr blockierend | **bestanden** – `TestCORS_AllowedOriginGetsAccessControlHeader`; real auf 131: `curl -H 'Origin: http://127.0.0.1:3099'` liefert `Access-Control-Allow-Origin: http://127.0.0.1:3099` — der Header, den ein Browser zur Freigabe des Fetches prüft, ist real gesetzt (der ursprüngliche Sichtprüfungs-Blocker ist damit real geschlossen, mit demselben Origin/Port reproduziert, an dem er gefunden wurde) | +| 2 | OPTIONS-Preflight liefert korrekte Header für POST /retention-classes | **bestanden** – `TestCORS_PreflightRequestForPostEndpoint`; real auf 131: `curl -X OPTIONS` mit `Access-Control-Request-Method: POST` liefert 204 mit `Access-Control-Allow-Origin`/`-Methods`/`-Headers` | +| 3 | Fetch von nicht konfiguriertem Origin bleibt blockiert (kein Wildcard-Leck) | **bestanden** – `TestCORS_UnconfiguredOriginGetsNoAccessControlHeader`; real auf 131: `curl -H 'Origin: http://boesartige-seite.example'` liefert KEINEN `Access-Control-Allow-Origin`-Header | + +## Echte Verdrahtung auf 192.168.1.131 + +- `retention-api` neu gebaut, `/etc/nexarch/archive-retention-api.env` + um `NEXARCH_RETENTION_CORS_ALLOWED_ORIGIN=http://127.0.0.1:3099` + ergänzt (RET-06-Dev-Server-Origin), Dienst neugestartet. +- Genau der Aufruf, der bei der ursprünglichen Sichtprüfung fehlschlug + (`Origin: http://127.0.0.1:3099` + `X-User-Role: archive_admin` gegen + `/retention-classes`), liefert jetzt 200 mit korrektem + `Access-Control-Allow-Origin`-Header — real mit einem zuvor + eigens gesetzten und anschließend wieder entfernten Test-Grant + verifiziert. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -> alle Archive-Pakete bestanden (inkl. retentionapi mit 3 neuen CORS-Tests) +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei +Pflichtprüfungen real erfüllt. Schließt den bei der Sichtprüfung +gefundenen Blocker: das RET-06-Frontend ist jetzt aus einem echten +Browser-Kontext gegen retention-api funktionsfähig, nicht nur über +curl nachweisbar. diff --git a/archive/internal/retentionapi/cors.go b/archive/internal/retentionapi/cors.go new file mode 100644 index 0000000..9bcbd97 --- /dev/null +++ b/archive/internal/retentionapi/cors.go @@ -0,0 +1,27 @@ +package retentionapi + +import "net/http" + +// CORS ist RET-10: retention-api hatte bislang keine CORS-Header, ein +// echter Browser blockierte daher Fetches vom RET-06-Frontend (anderer +// Origin/Port) — real per Sichtprüfung gefunden, nicht durch die +// ursprünglichen RET-06/RET-06-API-Prüfungen erfasst (curl/Vitest-Fetch +// erzwingen keine Same-Origin-Policy). Erlaubt GENAU EINEN konfigurierten +// Origin, kein Wildcard — verhindert, dass jede beliebige Website die +// Fristenkonfiguration im Namen eines eingeloggten Nutzers aufrufen kann. +func CORS(allowedOrigin string, next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && origin == allowedOrigin { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-User-Role") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + } +} diff --git a/archive/internal/retentionapi/cors_test.go b/archive/internal/retentionapi/cors_test.go new file mode 100644 index 0000000..53e24ed --- /dev/null +++ b/archive/internal/retentionapi/cors_test.go @@ -0,0 +1,86 @@ +package retentionapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const testAllowedOrigin = "http://localhost:3099" + +func noopHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +// TestCORS_AllowedOriginGetsAccessControlHeader ist die geforderte +// Pflichtprüfung: ein echter Browser-Fetch (simuliert über den Origin- +// Header, den nur ein Browser automatisch setzt) erhält die nötigen +// CORS-Header, die Same-Origin-Policy blockiert den Zugriff nicht mehr. +func TestCORS_AllowedOriginGetsAccessControlHeader(t *testing.T) { + handler := CORS(testAllowedOrigin, noopHandler()) + server := httptest.NewServer(handler) + defer server.Close() + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + req.Header.Set("Origin", testAllowedOrigin) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != testAllowedOrigin { + t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, testAllowedOrigin) + } +} + +// TestCORS_PreflightRequestForPostEndpoint ist die geforderte +// Pflichtprüfung: OPTIONS-Preflight liefert korrekte CORS-Header für +// POST /retention-classes. +func TestCORS_PreflightRequestForPostEndpoint(t *testing.T) { + handler := CORS(testAllowedOrigin, noopHandler()) + server := httptest.NewServer(handler) + defer server.Close() + + req, _ := http.NewRequest(http.MethodOptions, server.URL+"/retention-classes", nil) + req.Header.Set("Origin", testAllowedOrigin) + req.Header.Set("Access-Control-Request-Method", "POST") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("options: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != testAllowedOrigin { + t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, testAllowedOrigin) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { + t.Fatal("Access-Control-Allow-Methods fehlt in der preflight-antwort") + } +} + +// TestCORS_UnconfiguredOriginGetsNoAccessControlHeader ist die geforderte +// Pflichtprüfung: Fetch von einem NICHT konfigurierten Origin bleibt +// blockiert — kein Wildcard-Leck. +func TestCORS_UnconfiguredOriginGetsNoAccessControlHeader(t *testing.T) { + handler := CORS(testAllowedOrigin, noopHandler()) + server := httptest.NewServer(handler) + defer server.Close() + + req, _ := http.NewRequest(http.MethodGet, server.URL, nil) + req.Header.Set("Origin", "http://boesartige-seite.example") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Access-Control-Allow-Origin haette bei fremdem origin leer sein muessen, war %q", got) + } +}