diff --git a/backend/internal/application/auth/errs.go b/backend/internal/application/auth/errs.go index b2b1085b..7dad48a3 100644 --- a/backend/internal/application/auth/errs.go +++ b/backend/internal/application/auth/errs.go @@ -41,8 +41,6 @@ var ( ErrIdentityNotFound = errors.New("identity not found") // ErrIdentityProviderDeleteConflict 表示删除身份源会让用户失去最后一种登录方式。 ErrIdentityProviderDeleteConflict = errors.New("identity provider delete conflict") - // ErrIdentityProviderLogoUnavailable 表示身份源图标不可用或不符合代理安全策略。 - ErrIdentityProviderLogoUnavailable = errors.New("identity provider logo unavailable") // ErrIdentityProviderSuperAdminDefaultRoleNotAllowed 表示非 superadmin 不允许设置 superadmin 默认角色。 ErrIdentityProviderSuperAdminDefaultRoleNotAllowed = errors.New("only superadmin can set superadmin default role") // ErrTwoFactorSetupExpired 两步验证设置已过期。 diff --git a/backend/internal/application/auth/provider.go b/backend/internal/application/auth/provider.go index d56ee34d..13e47761 100644 --- a/backend/internal/application/auth/provider.go +++ b/backend/internal/application/auth/provider.go @@ -1,7 +1,6 @@ package auth import ( - "bytes" "context" "crypto/hmac" "crypto/sha256" @@ -11,10 +10,8 @@ import ( "errors" "fmt" "io" - "mime" "net/http" "net/url" - "path" "regexp" "strings" "time" @@ -80,11 +77,6 @@ type UserIdentityView struct { LastLoginAt *time.Time } -type IdentityProviderLogoAsset struct { - ContentType string - Content []byte -} - type UpsertIdentityProviderInput struct { ActorRole string Type string @@ -745,7 +737,6 @@ const ( providerIntentRegister = "register" providerIntentBind = "bind" providerHTTPTimeout = 10 * time.Second - providerLogoMaxBytes = 2 << 20 ) func normalizeProviderSlug(value string) string { @@ -767,124 +758,6 @@ func normalizeProviderIntent(value string) string { } } -func (s *Service) GetIdentityProviderLogo(ctx context.Context, slug string) (*IdentityProviderLogoAsset, error) { - provider, err := s.repo.GetIdentityProviderBySlug(ctx, normalizeProviderSlug(slug)) - if err != nil { - return nil, ErrIdentityProviderLogoUnavailable - } - logoURL := strings.TrimSpace(provider.LogoURL) - parsed, err := url.Parse(logoURL) - if err != nil || parsed == nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { - return nil, ErrIdentityProviderLogoUnavailable - } - - request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) - if err != nil { - return nil, err - } - request.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") - - response, err := s.providerHTTPClient.Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, ErrIdentityProviderLogoUnavailable - } - - content, err := io.ReadAll(io.LimitReader(response.Body, providerLogoMaxBytes+1)) - if err != nil { - return nil, err - } - if len(content) == 0 || len(content) > providerLogoMaxBytes { - return nil, ErrIdentityProviderLogoUnavailable - } - contentType := resolveIdentityProviderLogoContentType(response.Header.Get("Content-Type"), parsed.Path, content) - if contentType == "" { - return nil, ErrIdentityProviderLogoUnavailable - } - return &IdentityProviderLogoAsset{ - ContentType: contentType, - Content: content, - }, nil -} - -func resolveIdentityProviderLogoContentType(headerValue string, requestPath string, content []byte) string { - contentType := normalizeIdentityProviderLogoContentType(headerValue) - if isAllowedIdentityProviderLogoContentType(contentType) { - return contentType - } - if contentType == "" || contentType == "application/octet-stream" { - contentType = normalizeIdentityProviderLogoContentType(http.DetectContentType(content)) - if isAllowedIdentityProviderLogoContentType(contentType) { - return contentType - } - contentType = providerLogoContentTypeByExtension(requestPath) - if contentType == "image/svg+xml" && !looksLikeSVG(content) { - return "" - } - if isAllowedIdentityProviderLogoContentType(contentType) { - return contentType - } - } - return "" -} - -func normalizeIdentityProviderLogoContentType(value string) string { - contentType, _, err := mime.ParseMediaType(strings.TrimSpace(value)) - if err != nil { - contentType = strings.TrimSpace(value) - } - return strings.ToLower(contentType) -} - -func isAllowedIdentityProviderLogoContentType(contentType string) bool { - switch contentType { - case "image/avif", - "image/gif", - "image/jpeg", - "image/png", - "image/svg+xml", - "image/vnd.microsoft.icon", - "image/webp", - "image/x-icon": - return true - default: - return false - } -} - -func providerLogoContentTypeByExtension(requestPath string) string { - switch strings.ToLower(path.Ext(requestPath)) { - case ".avif": - return "image/avif" - case ".gif": - return "image/gif" - case ".ico": - return "image/x-icon" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".png": - return "image/png" - case ".svg": - return "image/svg+xml" - case ".webp": - return "image/webp" - default: - return "" - } -} - -func looksLikeSVG(content []byte) bool { - trimmed := bytes.TrimSpace(content) - if len(trimmed) == 0 { - return false - } - lowered := bytes.ToLower(trimmed) - return bytes.HasPrefix(lowered, []byte("alert(1)")) - })) - defer server.Close() - - repo := &providerLoginRepo{ - providersBySlug: map[string]*domainuser.IdentityProvider{ - "acme": { - Slug: "acme", - LogoURL: server.URL + "/logo.html", - }, - }, - } - service := NewService(config.Config{JWTSecret: "test-secret"}, repo, nil) - - if _, err := service.GetIdentityProviderLogo(context.Background(), "acme"); !errors.Is(err, ErrIdentityProviderLogoUnavailable) { - t.Fatalf("expected unavailable error, got %v", err) - } -} - func TestUnlinkCurrentUserIdentityAllowsLastIdentityWhenPasswordEnabled(t *testing.T) { repo := &providerLoginRepo{ credentialsByUserID: map[uint]*domainuser.Credential{ diff --git a/backend/internal/transport/http/auth/handler.go b/backend/internal/transport/http/auth/handler.go index 2742fffc..74d81ff5 100644 --- a/backend/internal/transport/http/auth/handler.go +++ b/backend/internal/transport/http/auth/handler.go @@ -113,22 +113,6 @@ func (h *Handler) LoginOptions(c *gin.Context) { response.Success(c, toLoginOptionsResponse(result)) } -func (h *Handler) IdentityProviderLogo(c *gin.Context) { - asset, err := h.service.GetIdentityProviderLogo(c.Request.Context(), c.Param("slug")) - if err != nil { - status := http.StatusBadGateway - if errors.Is(err, appauth.ErrIdentityProviderLogoUnavailable) { - status = http.StatusNotFound - } - response.ErrorFrom(c, status, err) - return - } - c.Header("Cache-Control", "public, max-age=3600") - c.Header("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; script-src 'none'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'") - c.Header("X-Content-Type-Options", "nosniff") - c.Data(http.StatusOK, asset.ContentType, asset.Content) -} - // StartEmailRegistration godoc // @Summary 发送邮箱注册验证码 // @Description 邮箱验证码注册开启时发送验证码;启用 Turnstile 后需要提交 turnstileToken diff --git a/backend/internal/transport/http/auth/router.go b/backend/internal/transport/http/auth/router.go index 5783913a..12fab3a7 100644 --- a/backend/internal/transport/http/auth/router.go +++ b/backend/internal/transport/http/auth/router.go @@ -16,7 +16,6 @@ func (m *Module) RegisterPublicRoutes(api *gin.RouterGroup) { api.GET("/auth/providers/:slug/start", m.Handler.StartProviderLogin) api.GET("/auth/providers/:slug/callback", m.Handler.ProviderCallback) api.POST("/auth/providers/:slug/callback", m.Handler.CompleteProviderLogin) - api.GET("/auth/providers/:slug/logo", m.Handler.IdentityProviderLogo) } // RegisterProtectedRoutes 注册需登录的鉴权路由。 diff --git a/backend/internal/transport/http/middleware/https_redirect.go b/backend/internal/transport/http/middleware/https_redirect.go new file mode 100644 index 00000000..64d20f2b --- /dev/null +++ b/backend/internal/transport/http/middleware/https_redirect.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// HTTPSRedirect 将生产环境的外部 HTTP 请求永久重定向到同主机 HTTPS。 +func HTTPSRedirect(env string) gin.HandlerFunc { + production := strings.EqualFold(strings.TrimSpace(env), "prod") || + strings.EqualFold(strings.TrimSpace(env), "production") + + return func(c *gin.Context) { + if !production || c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" || requestUsesHTTPS(c) { + c.Next() + return + } + + target := *c.Request.URL + target.Scheme = "https" + target.Host = c.Request.Host + c.Redirect(http.StatusPermanentRedirect, target.String()) + c.Abort() + } +} + +func requestUsesHTTPS(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + proto, _, _ := strings.Cut(c.GetHeader("X-Forwarded-Proto"), ",") + return strings.EqualFold(strings.TrimSpace(proto), "https") +} diff --git a/backend/internal/transport/http/middleware/https_redirect_test.go b/backend/internal/transport/http/middleware/https_redirect_test.go new file mode 100644 index 00000000..1a04bacf --- /dev/null +++ b/backend/internal/transport/http/middleware/https_redirect_test.go @@ -0,0 +1,73 @@ +package middleware + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHTTPSRedirectRedirectsProductionHTTP(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(HTTPSRedirect("production")) + router.GET("/chat", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://chat.example.com/chat?id=demo", nil)) + + if recorder.Code != http.StatusPermanentRedirect { + t.Fatalf("expected status 308, got %d", recorder.Code) + } + if got := recorder.Header().Get("Location"); got != "https://chat.example.com/chat?id=demo" { + t.Fatalf("unexpected redirect location: %q", got) + } +} + +func TestHTTPSRedirectAllowsHTTPSAndHealthChecks(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(HTTPSRedirect("prod")) + router.GET("/chat", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + router.GET("/healthz", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + httpsRequest := httptest.NewRequest(http.MethodGet, "https://chat.example.com/chat", nil) + httpsRequest.TLS = &tls.ConnectionState{} + httpsRecorder := httptest.NewRecorder() + router.ServeHTTP(httpsRecorder, httpsRequest) + if httpsRecorder.Code != http.StatusNoContent { + t.Fatalf("expected HTTPS request to pass, got %d", httpsRecorder.Code) + } + + healthRecorder := httptest.NewRecorder() + router.ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "http://chat.example.com/healthz", nil)) + if healthRecorder.Code != http.StatusOK { + t.Fatalf("expected HTTP health check to pass, got %d", healthRecorder.Code) + } +} + +func TestHTTPSRedirectAllowsForwardedHTTPS(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(HTTPSRedirect("prod")) + router.GET("/chat", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + request := httptest.NewRequest(http.MethodGet, "http://chat.example.com/chat", nil) + request.Header.Set("X-Forwarded-Proto", "https") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected forwarded HTTPS request to pass, got %d", recorder.Code) + } +} diff --git a/backend/internal/transport/http/middleware/security_headers.go b/backend/internal/transport/http/middleware/security_headers.go index 39850f1f..becd1c1c 100644 --- a/backend/internal/transport/http/middleware/security_headers.go +++ b/backend/internal/transport/http/middleware/security_headers.go @@ -2,25 +2,17 @@ package middleware import ( "net/http" - "strings" "github.com/gin-gonic/gin" ) -const defaultContentSecurityPolicy = "default-src 'self'; base-uri 'self'; object-src 'self' blob:; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; connect-src 'self' http: https: ws: wss: blob:; worker-src 'self' blob:; frame-src 'self' blob: https://challenges.cloudflare.com" - -// SecurityHeaders 为所有 HTTP 响应补充安全响应头;文件内容响应可在 handler 中覆盖为更严格策略。 -func SecurityHeaders(env string) gin.HandlerFunc { +// SecurityHeaders 为所有 HTTP 响应补充通用安全响应头。 +func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { header := c.Writer.Header() setHeaderIfEmpty(header, "X-Content-Type-Options", "nosniff") setHeaderIfEmpty(header, "X-Frame-Options", "DENY") - setHeaderIfEmpty(header, "Referrer-Policy", "no-referrer") setHeaderIfEmpty(header, "Permissions-Policy", "camera=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), browsing-topics=()") - setHeaderIfEmpty(header, "Content-Security-Policy", defaultContentSecurityPolicy) - if strings.EqualFold(strings.TrimSpace(env), "prod") || strings.EqualFold(strings.TrimSpace(env), "production") { - setHeaderIfEmpty(header, "Strict-Transport-Security", "max-age=31536000; includeSubDomains") - } c.Next() } } diff --git a/backend/internal/transport/http/server.go b/backend/internal/transport/http/server.go index aa057e99..fd135744 100644 --- a/backend/internal/transport/http/server.go +++ b/backend/internal/transport/http/server.go @@ -94,7 +94,8 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC }))) engine.Use(middleware.RequestID()) engine.Use(middleware.AccessLog(log)) - engine.Use(middleware.SecurityHeaders(snapshot.Env)) + engine.Use(middleware.HTTPSRedirect(snapshot.Env)) + engine.Use(middleware.SecurityHeaders()) engine.Use(middleware.CORS(snapshot.CORSAllowOrigin)) engine.GET("/healthz", func(c *gin.Context) { diff --git a/backend/internal/transport/http/server_test.go b/backend/internal/transport/http/server_test.go index f12d94e5..d27cda25 100644 --- a/backend/internal/transport/http/server_test.go +++ b/backend/internal/transport/http/server_test.go @@ -32,6 +32,9 @@ func TestVersionEndpointIsPublicAndUncached(t *testing.T) { if got := recorder.Header().Get("Pragma"); got != "no-cache" { t.Fatalf("expected version pragma no-cache, got %q", got) } + if got := recorder.Header().Get("Content-Security-Policy"); got != "" { + t.Fatalf("expected API response without Content-Security-Policy, got %q", got) + } if !strings.Contains(recorder.Body.String(), `"buildID"`) { t.Fatalf("expected version response to include buildID, got %q", recorder.Body.String()) } @@ -63,6 +66,9 @@ func TestFrontendStaticFallbackServesExportedPage(t *testing.T) { if got := recorder.Header().Get("Cache-Control"); got != "no-cache" { t.Fatalf("expected exported page no-cache, got %q", got) } + if got := recorder.Header().Get("Content-Security-Policy"); got != "" { + t.Fatalf("expected exported page without Content-Security-Policy, got %q", got) + } } func TestFrontendStaticCachesNextExportData(t *testing.T) { @@ -111,6 +117,9 @@ func TestFrontendStaticCachesImmutableBuildAssets(t *testing.T) { if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { t.Fatalf("expected immutable cache header, got %q", got) } + if got := recorder.Header().Get("Content-Security-Policy"); got != "" { + t.Fatalf("expected static asset without Content-Security-Policy, got %q", got) + } } func TestFrontendStaticFallbackSkipsAPIPaths(t *testing.T) { diff --git a/frontend/shared/components/identity-provider-icon.tsx b/frontend/shared/components/identity-provider-icon.tsx index b108fd23..3b6c322d 100644 --- a/frontend/shared/components/identity-provider-icon.tsx +++ b/frontend/shared/components/identity-provider-icon.tsx @@ -9,20 +9,6 @@ import { } from "@/shared/lib/identity-provider-icons"; import { cn } from "@/lib/utils"; -function resolveCustomIdentityProviderLogoURL(logoURL: string | undefined, slug: string): string { - const trimmed = logoURL?.trim() ?? ""; - if (!trimmed) { - return ""; - } - if (/^https?:\/\//i.test(trimmed)) { - return `/api/v1/auth/providers/${encodeURIComponent(slug)}/logo`; - } - if (trimmed.startsWith("/") && !trimmed.startsWith("//") && !trimmed.includes("\\")) { - return trimmed; - } - return ""; -} - export function IdentityProviderIcon({ name, slug, @@ -38,7 +24,7 @@ export function IdentityProviderIcon({ iconClassName?: string; fallbackClassName?: string; }) { - const customLogoURL = resolveCustomIdentityProviderLogoURL(logoURL, slug); + const customLogoURL = logoURL?.trim() ?? ""; const defaultIconURL = resolveIdentityProviderIconURL(name, slug); const iconCandidates = React.useMemo( () => [customLogoURL, defaultIconURL].filter((value): value is string => Boolean(value)),