diff --git a/backend/auth_passkey.go b/backend/auth_passkey.go index d0d700b..b182e53 100644 --- a/backend/auth_passkey.go +++ b/backend/auth_passkey.go @@ -112,8 +112,17 @@ func authAllowedRPOrigins() []string { out = append(out, origin) } + for _, origin := range configuredPublicAppOrigins() { + addOrigin(origin) + } + + scheme := "https" + if !httpsEnabled() { + scheme = "http" + } + for _, host := range localAppHostCandidates() { - addOrigin(originForHost("https", host, appPort())) + addOrigin(originForHost(scheme, host, appPort())) } // Explicit env values remain supported, but they extend the dynamic list @@ -125,6 +134,33 @@ func authAllowedRPOrigins() []string { return out } +func configuredPublicAppOrigins() []string { + keys := []string{ + "APP_PUBLIC_URLS", + "APP_PUBLIC_URL", + "PUBLIC_APP_URLS", + "PUBLIC_APP_URL", + "NSFWAPP_URL", + } + + out := make([]string, 0, len(keys)) + for _, key := range keys { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + continue + } + + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + } + + return out +} + func configuredAuthOrigins() []string { raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS")) @@ -381,6 +417,57 @@ func originIsAllowed(origin string, allowed []string) bool { return false } +func requestHostOriginAllowed(origin string, r *http.Request) bool { + normalizedOrigin, ok := canonicalOrigin(origin) + if !ok { + return false + } + + u, err := url.Parse(normalizedOrigin) + if err != nil { + return false + } + + originHost := normalizeOriginHost(u.Hostname()) + requestHost, requestPort := splitRequestHostPort(r.Host) + if originHost == "" || requestHost == "" || originHost != requestHost { + return false + } + + if strings.TrimSpace(u.Port()) != requestPort { + return false + } + + if u.Scheme == "https" { + return true + } + + return u.Scheme == "http" && isLocalWebAuthnHost(originHost) +} + +func splitRequestHostPort(host string) (string, string) { + host = strings.TrimSpace(host) + if host == "" { + return "", "" + } + + if h, p, err := net.SplitHostPort(host); err == nil { + return normalizeOriginHost(h), strings.TrimSpace(p) + } + + return normalizeOriginHost(host), "" +} + +func isLocalWebAuthnHost(host string) bool { + host = normalizeOriginHost(host) + if host == "localhost" { + return true + } + + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func (am *AuthManager) webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) { origin := originFromRequest(r) if origin == "" { @@ -388,7 +475,7 @@ func (am *AuthManager) webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, } allowedOrigins := authAllowedRPOrigins() - if !originIsAllowed(origin, allowedOrigins) { + if !originIsAllowed(origin, allowedOrigins) && !requestHostOriginAllowed(origin, r) { return nil, errors.New("origin not allowed for WebAuthn: " + origin) } diff --git a/backend/auth_passkey_test.go b/backend/auth_passkey_test.go new file mode 100644 index 0000000..7949cd8 --- /dev/null +++ b/backend/auth_passkey_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequestHostOriginAllowedAcceptsHTTPSFQDN(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "https://l14pbbk95100006.tegdssd.de:9999/api/auth/passkey/login/options", nil) + + if !requestHostOriginAllowed("https://l14pbbk95100006.tegdssd.de:9999", r) { + t.Fatal("expected matching HTTPS request host origin to be allowed") + } +} + +func TestRequestHostOriginAllowedRejectsDifferentHost(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "https://l14pbbk95100006.tegdssd.de:9999/api/auth/passkey/login/options", nil) + + if requestHostOriginAllowed("https://evil.example:9999", r) { + t.Fatal("expected different origin host to be rejected") + } +} + +func TestAuthAllowedRPOriginsIncludesConfiguredPublicURL(t *testing.T) { + t.Setenv("APP_PUBLIC_URL", "https://l14pbbk95100006.tegdssd.de:9999") + + origins := authAllowedRPOrigins() + if !originIsAllowed("https://l14pbbk95100006.tegdssd.de:9999", origins) { + t.Fatalf("expected configured public URL in allowed origins: %#v", origins) + } +} diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index cfa7622..5e94a33 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 41be240..5af03b1 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 09061b5..87cb3f3 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/server.go b/backend/server.go index b09f014..e367bcc 100644 --- a/backend/server.go +++ b/backend/server.go @@ -941,15 +941,23 @@ func publicAppURLs() []string { out := make([]string, 0, 8) seen := map[string]bool{} - for _, host := range localAppHostCandidates() { - u := originForHost(scheme, host, port) - if u == "" || seen[u] { - continue + addURL := func(raw string) { + u, ok := canonicalOrigin(raw) + if !ok || seen[u] { + return } seen[u] = true out = append(out, u) } + for _, u := range configuredPublicAppOrigins() { + addURL(u) + } + + for _, host := range localAppHostCandidates() { + addURL(originForHost(scheme, host, port)) + } + return out } diff --git a/frontend/src/components/ui/LoginPage.tsx b/frontend/src/components/ui/LoginPage.tsx index ca427a0..8f1c059 100644 --- a/frontend/src/components/ui/LoginPage.tsx +++ b/frontend/src/components/ui/LoginPage.tsx @@ -115,7 +115,7 @@ export default function LoginPage({ onLoggedIn }: Props) { if (raw.includes('passkey registration verify failed')) { return ( raw + - '\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.' + '\n\nPruefe, ob du den Passkey unter genau dieser Browser-URL registriert hast. Passkeys sind an Hostname und RP-ID gebunden.' ) } @@ -135,7 +135,7 @@ export default function LoginPage({ onLoggedIn }: Props) { ) { return ( raw + - '\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.' + '\n\nDas sieht nach einem Origin/RP-ID Problem aus. Pruefe, ob Hostname, HTTPS und die Passkey-Registrierung zusammenpassen.' ) } diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 6d1393a..22c1ced 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -1891,7 +1891,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { if (raw.includes('passkey registration verify failed')) { return ( raw + - '\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.' + '\n\nPruefe, ob du den Passkey unter genau dieser Browser-URL registriert hast. Passkeys sind an Hostname und RP-ID gebunden.' ) } @@ -1911,7 +1911,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ) { return ( raw + - '\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.' + '\n\nDas sieht nach einem Origin/RP-ID Problem aus. Pruefe, ob Hostname, HTTPS und die Passkey-Registrierung zusammenpassen.' ) }