From 4588d3dd0442299efc1bd36a249a0d104195cd91 Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 9 Oct 2026 20:14:26 +0100 Subject: [PATCH 1/2] fix(wordpress): Require allowlisted emails for protected access Reject unlisted addresses before sending sign-in emails, including existing policies. Wait for confirmation before consuming sign-in links. Use the existing FlatRun logo and product styles on access pages. Prepare the next beta release. --- CHANGELOG.md | 8 ++ VERSION | 2 +- internal/access/service.go | 24 +++++- internal/api/access_handlers.go | 55 +++++++++--- internal/api/access_handlers_test.go | 120 +++++++++++++++++++++++++-- internal/api/assets/flatrun-logo.svg | 10 +++ internal/api/deployment_actions.go | 9 +- internal/api/openapi.json | 40 ++++++++- internal/api/server.go | 6 +- 9 files changed, 249 insertions(+), 25 deletions(-) create mode 100644 internal/api/assets/flatrun-logo.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a089c8..8d2b5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [0.4.0-beta.11] - 2026-10-09 + +### Fixed +- Deployment access pages use the existing FlatRun logo and product control styles +- Unrecognized email addresses receive an access denial before any sign-in email is sent +- Protected WordPress paths require the FlatRun email allowlist, including existing policies +- Opening a sign-in link waits for confirmation before consuming it + ## [0.4.0-beta.10] - 2026-10-09 ### Fixed diff --git a/VERSION b/VERSION index 27eb834..27eb735 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-beta.10 +0.4.0-beta.11 diff --git a/internal/access/service.go b/internal/access/service.go index 7197386..5fa40f6 100644 --- a/internal/access/service.go +++ b/internal/access/service.go @@ -89,6 +89,9 @@ func Resolve(deployments []models.Deployment, host, requestPath string) (*models continue } copy := *domain.Access + if strings.EqualFold(deployments[i].Metadata.Type, "wordpress") { + copy.Mode = "allowlist" + } best = © bestLength = len(prefix) } @@ -100,13 +103,30 @@ func (s *Service) MagicLink(email, host, returnPath string) (string, error) { return s.sign(tokenPayload{Kind: "verify", Email: normalizeEmail(email), Host: Hostname(host), Return: SafeReturn(returnPath), Expiry: s.now().Add(15 * time.Minute).Unix()}) } -func (s *Service) VerifyMagicLink(value string) (string, string, string, error) { +func (s *Service) InspectMagicLink(value string) (string, string, string, error) { payload, err := s.verify(value, "verify") if err != nil { return "", "", "", err } + if _, err := os.Stat(s.usedLinkPath(value, payload.Expiry)); err == nil { + return "", "", "", fmt.Errorf("token is invalid or expired") + } else if !os.IsNotExist(err) { + return "", "", "", fmt.Errorf("check used access link: %w", err) + } + return payload.Email, payload.Host, payload.Return, nil +} + +func (s *Service) usedLinkPath(value string, expiry int64) string { digest := sha256.Sum256([]byte(value)) - path := filepath.Join(s.usedLinksDir, fmt.Sprintf("%d-%x", payload.Expiry, digest)) + return filepath.Join(s.usedLinksDir, fmt.Sprintf("%d-%x", expiry, digest)) +} + +func (s *Service) VerifyMagicLink(value string) (string, string, string, error) { + payload, err := s.verify(value, "verify") + if err != nil { + return "", "", "", err + } + path := s.usedLinkPath(value, payload.Expiry) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if os.IsExist(err) { return "", "", "", fmt.Errorf("token is invalid or expired") diff --git a/internal/api/access_handlers.go b/internal/api/access_handlers.go index d9971f9..66d3fdc 100644 --- a/internal/api/access_handlers.go +++ b/internal/api/access_handlers.go @@ -1,6 +1,7 @@ package api import ( + _ "embed" "fmt" "html" "log" @@ -19,21 +20,21 @@ type accessEmailSender interface { } const accessPageStyles = `` -const accessBrand = `
FlatRun
` +//go:embed assets/flatrun-logo.svg +var accessLogo string func accessPage(title, content string) string { - return `` + title + ` | FlatRun` + accessPageStyles + `
` + accessBrand + `
` + content + `

Protected by FlatRun

` + return `` + title + ` | FlatRun` + accessPageStyles + `
` + content + `

Protected by FlatRun

` } func (s *Server) checkApplicationAccess(c *gin.Context) { @@ -61,7 +62,12 @@ func (s *Server) requestApplicationAccess(c *gin.Context) { email := strings.TrimSpace(c.PostForm("email")) returnPath := access.SafeReturn(c.PostForm("return")) policy, ok := s.applicationAccessPolicy(c.Request.Host, returnPath) - if ok && s.access != nil && s.accessEmailSender != nil && access.Allows(policy, email) && s.access.AllowEmailRequest(c.Request.Host, email) { + if !ok || !access.Allows(policy, email) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusForbidden, accessPage("Access denied", `

Access denied

This email address is not allowed to access this application.

`)) + return + } + if s.access != nil && s.accessEmailSender != nil && s.access.AllowEmailRequest(c.Request.Host, email) { token, err := s.access.MagicLink(email, c.Request.Host, returnPath) if err == nil { scheme := c.GetHeader("X-Forwarded-Proto") @@ -96,11 +102,30 @@ func (s *Server) getAccessEmailTargets(c *gin.Context) { } func (s *Server) verifyApplicationAccess(c *gin.Context) { + s.handleApplicationAccessVerification(c, c.Query("token"), false) +} + +type applicationAccessConfirmationRequest struct { + Token string `json:"token" form:"token" binding:"required"` +} + +func (s *Server) confirmApplicationAccess(c *gin.Context) { + var request applicationAccessConfirmationRequest + if err := c.ShouldBind(&request); err != nil { + c.String(http.StatusBadRequest, "A sign-in token is required") + return + } + s.handleApplicationAccessVerification(c, request.Token, true) +} + +func (s *Server) handleApplicationAccessVerification(c *gin.Context, token string, confirm bool) { if s.access == nil { c.String(http.StatusServiceUnavailable, "Access service is unavailable") return } - email, host, returnPath, err := s.access.VerifyMagicLink(c.Query("token")) + c.Header("Cache-Control", "no-store") + c.Header("Referrer-Policy", "no-referrer") + email, host, returnPath, err := s.access.InspectMagicLink(token) if err != nil { c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") return @@ -110,6 +135,16 @@ func (s *Server) verifyApplicationAccess(c *gin.Context) { c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") return } + if !confirm { + c.Header("Content-Type", "text/html; charset=utf-8") + content := fmt.Sprintf(`

Confirm sign-in

Continue to the application with your verified email address.

`, html.EscapeString(token)) + c.String(http.StatusOK, accessPage("Confirm sign-in", content)) + return + } + if _, _, _, err := s.access.VerifyMagicLink(token); err != nil { + c.String(http.StatusUnauthorized, "This sign-in link is invalid or expired") + return + } session, err := s.access.Session(email, host, policy.SessionHours) if err != nil { c.String(http.StatusInternalServerError, "Could not create an access session") diff --git a/internal/api/access_handlers_test.go b/internal/api/access_handlers_test.go index 9e57118..063ea8d 100644 --- a/internal/api/access_handlers_test.go +++ b/internal/api/access_handlers_test.go @@ -15,16 +15,19 @@ import ( "github.com/flatrun/agent/internal/docker" "github.com/flatrun/agent/internal/notify" "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" "github.com/gin-gonic/gin" ) type recordingAccessSender struct { + calls int targetID string recipient string message string } func (s *recordingAccessSender) SendEmailTo(targetID, recipient string, message notify.Notification) error { + s.calls++ s.targetID = targetID s.recipient = recipient s.message = message.Message @@ -68,6 +71,7 @@ domains: router.GET("/api/access/check", server.checkApplicationAccess) router.POST("/api/access/request", server.requestApplicationAccess) router.GET("/api/access/verify", server.verifyApplicationAccess) + router.POST("/api/access/verify", server.confirmApplicationAccess) request := httptest.NewRequest(http.MethodGet, "/api/access/check", nil) request.Header.Set("X-Original-Host", "private.example.com") @@ -113,11 +117,33 @@ domains: request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response = httptest.NewRecorder() router.ServeHTTP(response, request) - if response.Code != http.StatusAccepted || sender.message != "" { + if response.Code != http.StatusForbidden || sender.message != "" || sender.calls != 2 { t.Fatalf("unlisted email response = %d, message = %q", response.Code, sender.message) } request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "other.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("wrong-host verification response = %d", response.Code) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "private.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK || len(response.Result().Cookies()) != 0 || !strings.Contains(response.Body.String(), "Confirm sign-in") { + t.Fatalf("verification preview response = %d, cookies = %v", response.Code, response.Result().Cookies()) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/verify?"+parsed.RawQuery, nil) + request.Host = "private.example.com" + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("repeated link preview response = %d", response.Code) + } + request = httptest.NewRequest(http.MethodPost, "/api/access/verify", strings.NewReader(url.Values{"token": {parsed.Query().Get("token")}}.Encode())) request.Host = "private.example.com" + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response = httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 { @@ -159,6 +185,90 @@ domains: } } +func TestWordPressAccessRequiresAllowlistedEmailBeforeSending(t *testing.T) { + for _, test := range []struct { + name, mode, email string + allowed []string + status, emails int + }{ + {"unknown address", "allowlist", "unknown@example.com", []string{"person@example.com"}, http.StatusForbidden, 0}, + {"legacy open policy", "any_verified", "unknown@example.com", nil, http.StatusForbidden, 0}, + {"legacy policy with allowlist", "any_verified", "unknown@example.com", []string{"person@example.com"}, http.StatusForbidden, 0}, + {"recognized address", "allowlist", "person@example.com", []string{"person@example.com"}, http.StatusAccepted, 1}, + {"recognized domain", "allowlist", "person@example.com", []string{"@example.com"}, http.StatusAccepted, 1}, + } { + t.Run(test.name, func(t *testing.T) { + base := t.TempDir() + createTestDeployment(t, base, "wordpress-site", &models.ServiceMetadata{ + Name: "wordpress-site", Type: "wordpress", Domains: []models.DomainConfig{{ + ID: "login", Service: "wordpress", ContainerPort: 80, Domain: "wordpress.example.com", PathPrefix: "/wp-login.php", + Access: &models.DomainAccessConfig{Enabled: true, Mode: test.mode, AllowedEmails: test.allowed, EmailTargetID: "smtp"}, + }}, + }) + service, err := access.New(base) + if err != nil { + t.Fatal(err) + } + sender := &recordingAccessSender{} + server := &Server{manager: docker.NewManager(base), access: service, accessEmailSender: sender} + router := gin.New() + router.POST("/api/access/request", server.requestApplicationAccess) + router.GET("/api/access/check", server.checkApplicationAccess) + request := httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ + "email": {test.email}, "return": {"/wp-login.php"}, + }.Encode())) + request.Host = "wordpress.example.com" + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != test.status || sender.calls != test.emails { + t.Fatalf("status = %d, email attempts = %d", response.Code, sender.calls) + } + session, err := service.Session("unknown@other.example", "wordpress.example.com", 24) + if err != nil { + t.Fatal(err) + } + request = httptest.NewRequest(http.MethodGet, "/api/access/check", nil) + request.Header.Set("X-Original-Host", "wordpress.example.com") + request.Header.Set("X-Original-URI", "/wp-login.php") + request.AddCookie(&http.Cookie{Name: access.CookieName, Value: session}) + response = httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("unlisted existing session response = %d", response.Code) + } + }) + } +} + +func TestWordPressDomainAccessRejectsOpenPolicyThroughHTTP(t *testing.T) { + base := t.TempDir() + createTestDeployment(t, base, "wordpress-site", &models.ServiceMetadata{ + Name: "wordpress-site", Type: "wordpress", Domains: []models.DomainConfig{{ + ID: "login", Service: "web", ContainerPort: 80, Domain: "wordpress.example.com", PathPrefix: "/wp-login.php", + }}, + }) + server := &Server{manager: docker.NewManager(base)} + router := gin.New() + router.POST("/deployments/:name/domains", server.addDomain) + router.PUT("/deployments/:name/domains/:domainId", server.updateDomain) + router.PUT("/deployments/:name/metadata", server.updateDeploymentMetadata) + domain := `{"id":"login","service":"web","domain":"wordpress.example.com","path_prefix":"/wp-login.php","access":{"enabled":true,"mode":"any_verified","email_target_id":"smtp"}}` + for _, requestCase := range []struct{ method, path, body string }{ + {http.MethodPost, "/deployments/wordpress-site/domains", domain}, + {http.MethodPut, "/deployments/wordpress-site/domains/login", domain}, + {http.MethodPut, "/deployments/wordpress-site/metadata", `{"domains":[` + domain + `]}`}, + } { + request := httptest.NewRequest(requestCase.method, requestCase.path, strings.NewReader(requestCase.body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "WordPress access requires an email allowlist") { + t.Fatalf("%s: status = %d, body = %s", requestCase.path, response.Code, response.Body.String()) + } + } +} + func TestAccessEmailTargetsRespectDeploymentGrants(t *testing.T) { base := t.TempDir() cfg := &config.Config{Auth: config.AuthConfig{Enabled: true, JWTSecret: "access-test-secret"}} @@ -230,14 +340,14 @@ func TestApplicationAccessLoginRejectsUnsafeReturnPath(t *testing.T) { if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `name="return" value="/"`) { t.Fatalf("unsafe return path was accepted: %d %s", response.Code, response.Body.String()) } - for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Verify your email"} { + for _, expected := range []string{"FlatRun", "Protected by FlatRun", accessLogo, "Verify your email"} { if !strings.Contains(response.Body.String(), expected) { t.Fatalf("access page is missing %q", expected) } } } -func TestApplicationAccessConfirmationUsesFlatRunBranding(t *testing.T) { +func TestApplicationAccessDenialUsesFlatRunBranding(t *testing.T) { server := &Server{} router := gin.New() router.POST("/api/access/request", server.requestApplicationAccess) @@ -247,10 +357,10 @@ func TestApplicationAccessConfirmationUsesFlatRunBranding(t *testing.T) { request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response := httptest.NewRecorder() router.ServeHTTP(response, request) - if response.Code != http.StatusAccepted { + if response.Code != http.StatusForbidden { t.Fatalf("confirmation response = %d", response.Code) } - for _, expected := range []string{"FlatRun", "Protected by FlatRun", "brand-mark", "Check your email"} { + for _, expected := range []string{"FlatRun", "Protected by FlatRun", accessLogo, "Access denied"} { if !strings.Contains(response.Body.String(), expected) { t.Fatalf("confirmation page is missing %q", expected) } diff --git a/internal/api/assets/flatrun-logo.svg b/internal/api/assets/flatrun-logo.svg new file mode 100644 index 0000000..6977163 --- /dev/null +++ b/internal/api/assets/flatrun-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/internal/api/deployment_actions.go b/internal/api/deployment_actions.go index 96c0390..87ded65 100644 --- a/internal/api/deployment_actions.go +++ b/internal/api/deployment_actions.go @@ -110,7 +110,7 @@ func (s *Server) applyDeploymentDelete(name string, opts deploymentDeleteOptions // mutateDomainAdd validates the new domain and appends it to the // deployment metadata in memory only; persisting is the caller's job. func (s *Server) mutateDomainAdd(deployment *models.Deployment, domain *models.DomainConfig) error { - if err := s.validateDomainAccess(domain.Access); err != nil { + if err := s.validateDomainAccess(domain.Access, deployment.Metadata); err != nil { return err } if domain.Domain == "" { @@ -163,7 +163,7 @@ func (s *Server) mutateDomainAdd(deployment *models.Deployment, domain *models.D // mutateDomainUpdate replaces the domain with the given ID in memory // only; persisting is the caller's job. func (s *Server) mutateDomainUpdate(deployment *models.Deployment, domainID string, updated *models.DomainConfig) error { - if err := s.validateDomainAccess(updated.Access); err != nil { + if err := s.validateDomainAccess(updated.Access, deployment.Metadata); err != nil { return err } if updated.Domain == "" { @@ -199,10 +199,13 @@ func (s *Server) mutateDomainUpdate(deployment *models.Deployment, domainID stri return apiErrf(http.StatusNotFound, "Domain not found") } -func (s *Server) validateDomainAccess(policy *models.DomainAccessConfig) error { +func (s *Server) validateDomainAccess(policy *models.DomainAccessConfig, metadata *models.ServiceMetadata) error { if policy == nil || !policy.Enabled { return nil } + if metadata != nil && strings.EqualFold(metadata.Type, "wordpress") && policy.Mode != "allowlist" { + return apiErrf(http.StatusBadRequest, "WordPress access requires an email allowlist") + } if policy.Mode != "allowlist" && policy.Mode != "any_verified" { return apiErrf(http.StatusBadRequest, "Access mode must be allowlist or any_verified") } diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 79dfc56..a4d5c23 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "FlatRun Agent API", "description": "Generated from the agent's routes and the types its handlers bind and return.", - "version": "0.4.0-beta.10" + "version": "0.4.0-beta.11" }, "paths": { "/api/access/check": { @@ -74,6 +74,27 @@ "description": "Success" } } + }, + "post": { + "operationId": "post-access-verify", + "tags": [ + "access" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/api.applicationAccessConfirmationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } } }, "/api/agent/update": { @@ -12218,6 +12239,23 @@ "restart" ] }, + "api.applicationAccessConfirmationRequest": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "x-property-order": [ + "token" + ], + "x-columns": [ + "token" + ], + "required": [ + "token" + ] + }, "api.assistRequest": { "type": "object", "properties": { diff --git a/internal/api/server.go b/internal/api/server.go index bb24260..e2daee7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -468,6 +468,7 @@ func (s *Server) setupRoutes() { api.GET("/access/login", s.applicationAccessLogin) api.POST("/access/request", s.requestApplicationAccess) api.GET("/access/verify", s.verifyApplicationAccess) + api.POST("/access/verify", s.confirmApplicationAccess) // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec) @@ -2183,17 +2184,16 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { seenServices[healthCheck.Service] = struct{}{} } } + metadata := mergeMetadata(deployment.Metadata, &incoming, sentFields) if _, sentDomains := sentFields["domains"]; sentDomains { for i := range incoming.Domains { - if err := s.validateDomainAccess(incoming.Domains[i].Access); err != nil { + if err := s.validateDomainAccess(incoming.Domains[i].Access, metadata); err != nil { respondAPIError(c, err) return } } } - metadata := mergeMetadata(deployment.Metadata, &incoming, sentFields) - if err := s.manager.SaveMetadata(name, metadata); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), From 6e885a9abca27fd736056f35a3c17c5b09a33a69 Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 9 Oct 2026 20:31:28 +0100 Subject: [PATCH 2/2] fix(access): Respect configured deployment access modes Access restrictions follow each deployment policy. Production sites can require allowlisted emails without changing the available modes for other deployments. --- CHANGELOG.md | 1 - internal/access/service.go | 3 -- internal/api/access_handlers_test.go | 58 ++++++++-------------------- internal/api/deployment_actions.go | 9 ++--- internal/api/server.go | 5 ++- 5 files changed, 23 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2b5d8..9fe170e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ ### Fixed - Deployment access pages use the existing FlatRun logo and product control styles - Unrecognized email addresses receive an access denial before any sign-in email is sent -- Protected WordPress paths require the FlatRun email allowlist, including existing policies - Opening a sign-in link waits for confirmation before consuming it ## [0.4.0-beta.10] - 2026-10-09 diff --git a/internal/access/service.go b/internal/access/service.go index 5fa40f6..64515c5 100644 --- a/internal/access/service.go +++ b/internal/access/service.go @@ -89,9 +89,6 @@ func Resolve(deployments []models.Deployment, host, requestPath string) (*models continue } copy := *domain.Access - if strings.EqualFold(deployments[i].Metadata.Type, "wordpress") { - copy.Mode = "allowlist" - } best = © bestLength = len(prefix) } diff --git a/internal/api/access_handlers_test.go b/internal/api/access_handlers_test.go index 063ea8d..c4ebc29 100644 --- a/internal/api/access_handlers_test.go +++ b/internal/api/access_handlers_test.go @@ -185,23 +185,23 @@ domains: } } -func TestWordPressAccessRequiresAllowlistedEmailBeforeSending(t *testing.T) { +func TestApplicationAccessRespectsConfiguredPolicyBeforeSending(t *testing.T) { for _, test := range []struct { name, mode, email string allowed []string status, emails int }{ {"unknown address", "allowlist", "unknown@example.com", []string{"person@example.com"}, http.StatusForbidden, 0}, - {"legacy open policy", "any_verified", "unknown@example.com", nil, http.StatusForbidden, 0}, - {"legacy policy with allowlist", "any_verified", "unknown@example.com", []string{"person@example.com"}, http.StatusForbidden, 0}, + {"open policy", "any_verified", "unknown@example.com", nil, http.StatusAccepted, 1}, + {"open policy with allowlist", "any_verified", "unknown@example.com", []string{"person@example.com"}, http.StatusAccepted, 1}, {"recognized address", "allowlist", "person@example.com", []string{"person@example.com"}, http.StatusAccepted, 1}, {"recognized domain", "allowlist", "person@example.com", []string{"@example.com"}, http.StatusAccepted, 1}, } { t.Run(test.name, func(t *testing.T) { base := t.TempDir() - createTestDeployment(t, base, "wordpress-site", &models.ServiceMetadata{ - Name: "wordpress-site", Type: "wordpress", Domains: []models.DomainConfig{{ - ID: "login", Service: "wordpress", ContainerPort: 80, Domain: "wordpress.example.com", PathPrefix: "/wp-login.php", + createTestDeployment(t, base, "protected-site", &models.ServiceMetadata{ + Name: "protected-site", Type: "web", Domains: []models.DomainConfig{{ + ID: "login", Service: "web", ContainerPort: 80, Domain: "protected.example.com", PathPrefix: "/private", Access: &models.DomainAccessConfig{Enabled: true, Mode: test.mode, AllowedEmails: test.allowed, EmailTargetID: "smtp"}, }}, }) @@ -215,60 +215,36 @@ func TestWordPressAccessRequiresAllowlistedEmailBeforeSending(t *testing.T) { router.POST("/api/access/request", server.requestApplicationAccess) router.GET("/api/access/check", server.checkApplicationAccess) request := httptest.NewRequest(http.MethodPost, "/api/access/request", strings.NewReader(url.Values{ - "email": {test.email}, "return": {"/wp-login.php"}, + "email": {test.email}, "return": {"/private"}, }.Encode())) - request.Host = "wordpress.example.com" + request.Host = "protected.example.com" request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != test.status || sender.calls != test.emails { t.Fatalf("status = %d, email attempts = %d", response.Code, sender.calls) } - session, err := service.Session("unknown@other.example", "wordpress.example.com", 24) + session, err := service.Session("unknown@other.example", "protected.example.com", 24) if err != nil { t.Fatal(err) } request = httptest.NewRequest(http.MethodGet, "/api/access/check", nil) - request.Header.Set("X-Original-Host", "wordpress.example.com") - request.Header.Set("X-Original-URI", "/wp-login.php") + request.Header.Set("X-Original-Host", "protected.example.com") + request.Header.Set("X-Original-URI", "/private") request.AddCookie(&http.Cookie{Name: access.CookieName, Value: session}) response = httptest.NewRecorder() router.ServeHTTP(response, request) - if response.Code != http.StatusUnauthorized { - t.Fatalf("unlisted existing session response = %d", response.Code) + expected := http.StatusUnauthorized + if test.mode == "any_verified" { + expected = http.StatusNoContent + } + if response.Code != expected { + t.Fatalf("existing session response = %d, expected %d", response.Code, expected) } }) } } -func TestWordPressDomainAccessRejectsOpenPolicyThroughHTTP(t *testing.T) { - base := t.TempDir() - createTestDeployment(t, base, "wordpress-site", &models.ServiceMetadata{ - Name: "wordpress-site", Type: "wordpress", Domains: []models.DomainConfig{{ - ID: "login", Service: "web", ContainerPort: 80, Domain: "wordpress.example.com", PathPrefix: "/wp-login.php", - }}, - }) - server := &Server{manager: docker.NewManager(base)} - router := gin.New() - router.POST("/deployments/:name/domains", server.addDomain) - router.PUT("/deployments/:name/domains/:domainId", server.updateDomain) - router.PUT("/deployments/:name/metadata", server.updateDeploymentMetadata) - domain := `{"id":"login","service":"web","domain":"wordpress.example.com","path_prefix":"/wp-login.php","access":{"enabled":true,"mode":"any_verified","email_target_id":"smtp"}}` - for _, requestCase := range []struct{ method, path, body string }{ - {http.MethodPost, "/deployments/wordpress-site/domains", domain}, - {http.MethodPut, "/deployments/wordpress-site/domains/login", domain}, - {http.MethodPut, "/deployments/wordpress-site/metadata", `{"domains":[` + domain + `]}`}, - } { - request := httptest.NewRequest(requestCase.method, requestCase.path, strings.NewReader(requestCase.body)) - request.Header.Set("Content-Type", "application/json") - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "WordPress access requires an email allowlist") { - t.Fatalf("%s: status = %d, body = %s", requestCase.path, response.Code, response.Body.String()) - } - } -} - func TestAccessEmailTargetsRespectDeploymentGrants(t *testing.T) { base := t.TempDir() cfg := &config.Config{Auth: config.AuthConfig{Enabled: true, JWTSecret: "access-test-secret"}} diff --git a/internal/api/deployment_actions.go b/internal/api/deployment_actions.go index 87ded65..96c0390 100644 --- a/internal/api/deployment_actions.go +++ b/internal/api/deployment_actions.go @@ -110,7 +110,7 @@ func (s *Server) applyDeploymentDelete(name string, opts deploymentDeleteOptions // mutateDomainAdd validates the new domain and appends it to the // deployment metadata in memory only; persisting is the caller's job. func (s *Server) mutateDomainAdd(deployment *models.Deployment, domain *models.DomainConfig) error { - if err := s.validateDomainAccess(domain.Access, deployment.Metadata); err != nil { + if err := s.validateDomainAccess(domain.Access); err != nil { return err } if domain.Domain == "" { @@ -163,7 +163,7 @@ func (s *Server) mutateDomainAdd(deployment *models.Deployment, domain *models.D // mutateDomainUpdate replaces the domain with the given ID in memory // only; persisting is the caller's job. func (s *Server) mutateDomainUpdate(deployment *models.Deployment, domainID string, updated *models.DomainConfig) error { - if err := s.validateDomainAccess(updated.Access, deployment.Metadata); err != nil { + if err := s.validateDomainAccess(updated.Access); err != nil { return err } if updated.Domain == "" { @@ -199,13 +199,10 @@ func (s *Server) mutateDomainUpdate(deployment *models.Deployment, domainID stri return apiErrf(http.StatusNotFound, "Domain not found") } -func (s *Server) validateDomainAccess(policy *models.DomainAccessConfig, metadata *models.ServiceMetadata) error { +func (s *Server) validateDomainAccess(policy *models.DomainAccessConfig) error { if policy == nil || !policy.Enabled { return nil } - if metadata != nil && strings.EqualFold(metadata.Type, "wordpress") && policy.Mode != "allowlist" { - return apiErrf(http.StatusBadRequest, "WordPress access requires an email allowlist") - } if policy.Mode != "allowlist" && policy.Mode != "any_verified" { return apiErrf(http.StatusBadRequest, "Access mode must be allowlist or any_verified") } diff --git a/internal/api/server.go b/internal/api/server.go index e2daee7..4de4a72 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2184,16 +2184,17 @@ func (s *Server) updateDeploymentMetadata(c *gin.Context) { seenServices[healthCheck.Service] = struct{}{} } } - metadata := mergeMetadata(deployment.Metadata, &incoming, sentFields) if _, sentDomains := sentFields["domains"]; sentDomains { for i := range incoming.Domains { - if err := s.validateDomainAccess(incoming.Domains[i].Access, metadata); err != nil { + if err := s.validateDomainAccess(incoming.Domains[i].Access); err != nil { respondAPIError(c, err) return } } } + metadata := mergeMetadata(deployment.Metadata, &incoming, sentFields) + if err := s.manager.SaveMetadata(name, metadata); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(),