-
Notifications
You must be signed in to change notification settings - Fork 3
feat(access): Gate domain routes with email verification #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f05a112
feat(access): Gate domain routes with email verification
nfebe 179be29
fix(access): Deny requests when access policies cannot be loaded
nfebe f79e1de
fix(access): Address access review findings
nfebe 156bf7e
fix(access): Align access request validation
nfebe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,270 @@ | ||
| package access | ||
|
|
||
| import ( | ||
| "crypto/hmac" | ||
| "crypto/rand" | ||
| "crypto/sha256" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net" | ||
| "net/mail" | ||
| "os" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/flatrun/agent/pkg/models" | ||
| ) | ||
|
|
||
| const CookieName = "flatrun_access" | ||
|
|
||
| type Service struct { | ||
| secret []byte | ||
| usedLinksDir string | ||
| now func() time.Time | ||
| mu sync.Mutex | ||
| lastRequests map[string]time.Time | ||
| lastPrune time.Time | ||
| } | ||
|
|
||
| type tokenPayload struct { | ||
| Kind string `json:"kind"` | ||
| Email string `json:"email"` | ||
| Host string `json:"host"` | ||
| Return string `json:"return,omitempty"` | ||
| Expiry int64 `json:"expiry"` | ||
| } | ||
|
|
||
| func New(basePath string) (*Service, error) { | ||
| dir := filepath.Join(basePath, ".flatrun") | ||
| if err := os.MkdirAll(dir, 0700); err != nil { | ||
| return nil, fmt.Errorf("create access directory: %w", err) | ||
| } | ||
| path := filepath.Join(dir, "access-secret") | ||
| usedLinksDir := filepath.Join(dir, "used-access-links") | ||
| if err := os.MkdirAll(usedLinksDir, 0700); err != nil { | ||
| return nil, fmt.Errorf("create used access links directory: %w", err) | ||
| } | ||
| secret, err := os.ReadFile(path) | ||
| if os.IsNotExist(err) { | ||
| secret = make([]byte, 32) | ||
| if _, err := rand.Read(secret); err != nil { | ||
| return nil, fmt.Errorf("generate access secret: %w", err) | ||
| } | ||
| if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(secret)), 0600); err != nil { | ||
| return nil, fmt.Errorf("save access secret: %w", err) | ||
| } | ||
| return newService(secret, usedLinksDir), nil | ||
| } | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read access secret: %w", err) | ||
| } | ||
| secret, err = base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(secret))) | ||
| if err != nil || len(secret) != 32 { | ||
| return nil, fmt.Errorf("access secret is invalid") | ||
| } | ||
| return newService(secret, usedLinksDir), nil | ||
| } | ||
|
|
||
| func Resolve(deployments []models.Deployment, host, requestPath string) (*models.DomainAccessConfig, bool) { | ||
| host = Hostname(host) | ||
| bestLength := -1 | ||
| var best *models.DomainAccessConfig | ||
| for i := range deployments { | ||
| if deployments[i].Metadata == nil { | ||
| continue | ||
| } | ||
| for _, domain := range deployments[i].Metadata.GetDomains() { | ||
| if !matchesHost(domain, host) || domain.Access == nil || !domain.Access.Enabled { | ||
| continue | ||
| } | ||
| prefix := domain.PathPrefix | ||
| if prefix == "" { | ||
| prefix = "/" | ||
| } | ||
| if !strings.HasPrefix(requestPath, prefix) || len(prefix) <= bestLength { | ||
| continue | ||
| } | ||
| copy := *domain.Access | ||
| best = © | ||
| bestLength = len(prefix) | ||
| } | ||
| } | ||
| return best, best != nil | ||
| } | ||
|
|
||
| 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) { | ||
| payload, err := s.verify(value, "verify") | ||
| if err != nil { | ||
| return "", "", "", err | ||
| } | ||
| digest := sha256.Sum256([]byte(value)) | ||
| path := filepath.Join(s.usedLinksDir, fmt.Sprintf("%d-%x", payload.Expiry, digest)) | ||
| 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") | ||
| } | ||
| if err != nil { | ||
| return "", "", "", fmt.Errorf("record used access link: %w", err) | ||
| } | ||
| if err := file.Close(); err != nil { | ||
| return "", "", "", fmt.Errorf("close used access link: %w", err) | ||
| } | ||
| s.pruneUsedLinks() | ||
| return payload.Email, payload.Host, payload.Return, nil | ||
| } | ||
|
|
||
| func (s *Service) pruneUsedLinks() { | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| if s.now().Sub(s.lastPrune) < time.Hour { | ||
| return | ||
| } | ||
| s.lastPrune = s.now() | ||
| entries, err := os.ReadDir(s.usedLinksDir) | ||
| if err != nil { | ||
| return | ||
| } | ||
| for _, entry := range entries { | ||
| if entry.IsDir() { | ||
| continue | ||
| } | ||
| expiry, err := strconv.ParseInt(strings.SplitN(entry.Name(), "-", 2)[0], 10, 64) | ||
| if err == nil && expiry < s.now().Unix() { | ||
| _ = os.Remove(filepath.Join(s.usedLinksDir, entry.Name())) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (s *Service) AllowEmailRequest(host, email string) bool { | ||
| key := Hostname(host) + "\x00" + normalizeEmail(email) | ||
| now := s.now() | ||
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| for existing, last := range s.lastRequests { | ||
| if now.Sub(last) >= time.Minute { | ||
| delete(s.lastRequests, existing) | ||
| } | ||
| } | ||
| if last, ok := s.lastRequests[key]; ok && now.Sub(last) < time.Minute { | ||
| return false | ||
| } | ||
| s.lastRequests[key] = now | ||
| return true | ||
| } | ||
|
|
||
| func (s *Service) Session(email, host string, hours int) (string, error) { | ||
| if hours <= 0 { | ||
| hours = 24 | ||
| } | ||
| return s.sign(tokenPayload{Kind: "session", Email: normalizeEmail(email), Host: Hostname(host), Expiry: s.now().Add(time.Duration(hours) * time.Hour).Unix()}) | ||
| } | ||
|
|
||
| func (s *Service) ValidateSession(value, host string, policy *models.DomainAccessConfig) bool { | ||
| payload, err := s.verify(value, "session") | ||
| return err == nil && payload.Host == Hostname(host) && Allows(policy, payload.Email) | ||
| } | ||
|
|
||
| func Allows(policy *models.DomainAccessConfig, email string) bool { | ||
| if policy == nil || !policy.Enabled || !ValidEmail(email) { | ||
| return false | ||
| } | ||
| if policy.Mode == "any_verified" { | ||
| return true | ||
| } | ||
| email = normalizeEmail(email) | ||
| for _, allowed := range policy.AllowedEmails { | ||
| if normalizeEmail(allowed) == email { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func ValidEmail(value string) bool { | ||
| value = strings.TrimSpace(value) | ||
| address, err := mail.ParseAddress(value) | ||
| return err == nil && strings.EqualFold(address.Address, value) | ||
| } | ||
|
|
||
| func (s *Service) sign(payload tokenPayload) (string, error) { | ||
| data, err := json.Marshal(payload) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| encoded := base64.RawURLEncoding.EncodeToString(data) | ||
| mac := hmac.New(sha256.New, s.secret) | ||
| _, _ = mac.Write([]byte(encoded)) | ||
| return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil | ||
| } | ||
|
|
||
| func (s *Service) verify(value, kind string) (tokenPayload, error) { | ||
| var payload tokenPayload | ||
| parts := strings.Split(value, ".") | ||
| if len(parts) != 2 { | ||
| return payload, fmt.Errorf("token is invalid") | ||
| } | ||
| signature, err := base64.RawURLEncoding.DecodeString(parts[1]) | ||
| if err != nil { | ||
| return payload, fmt.Errorf("token is invalid") | ||
| } | ||
| mac := hmac.New(sha256.New, s.secret) | ||
| _, _ = mac.Write([]byte(parts[0])) | ||
| if !hmac.Equal(signature, mac.Sum(nil)) { | ||
| return payload, fmt.Errorf("token is invalid") | ||
| } | ||
| data, err := base64.RawURLEncoding.DecodeString(parts[0]) | ||
| if err != nil || json.Unmarshal(data, &payload) != nil || payload.Kind != kind || payload.Expiry < s.now().Unix() { | ||
| return tokenPayload{}, fmt.Errorf("token is invalid or expired") | ||
| } | ||
| return payload, nil | ||
| } | ||
|
|
||
| func matchesHost(domain models.DomainConfig, host string) bool { | ||
| if Hostname(domain.Domain) == host { | ||
| return true | ||
| } | ||
| for _, alias := range domain.Aliases { | ||
| if Hostname(alias) == host { | ||
| return true | ||
| } | ||
| } | ||
| for _, alias := range domain.RouteOnlyAliases { | ||
| if Hostname(alias) == host { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func Hostname(value string) string { | ||
| value = strings.TrimSpace(strings.ToLower(value)) | ||
| if host, _, err := net.SplitHostPort(value); err == nil { | ||
| return strings.TrimSuffix(host, ".") | ||
| } | ||
| return strings.TrimSuffix(value, ".") | ||
| } | ||
|
|
||
| func normalizeEmail(value string) string { | ||
| return strings.ToLower(strings.TrimSpace(value)) | ||
| } | ||
|
|
||
| func SafeReturn(value string) string { | ||
| if !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") { | ||
| return "/" | ||
| } | ||
| return value | ||
| } | ||
|
|
||
| func newService(secret []byte, usedLinksDir string) *Service { | ||
| return &Service{ | ||
| secret: secret, usedLinksDir: usedLinksDir, now: time.Now, lastRequests: make(map[string]time.Time), | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| package access | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/flatrun/agent/pkg/models" | ||
| ) | ||
|
|
||
| func TestResolveUsesTheMostSpecificProtectedPath(t *testing.T) { | ||
| deployments := []models.Deployment{{Metadata: &models.ServiceMetadata{Domains: []models.DomainConfig{ | ||
| {Domain: "app.example.com", PathPrefix: "/", Access: &models.DomainAccessConfig{Enabled: true, Mode: "any_verified"}}, | ||
| {Domain: "app.example.com", PathPrefix: "/admin", Access: &models.DomainAccessConfig{Enabled: true, Mode: "allowlist", AllowedEmails: []string{"admin@example.com"}}}, | ||
| }}}} | ||
|
|
||
| policy, ok := Resolve(deployments, "app.example.com", "/admin/users") | ||
| if !ok || policy.Mode != "allowlist" { | ||
| t.Fatalf("Resolve() = %#v, %v", policy, ok) | ||
| } | ||
| if Allows(policy, "visitor@example.com") { | ||
| t.Fatal("visitor unexpectedly passed the admin allowlist") | ||
| } | ||
| } | ||
|
|
||
| func TestMagicLinkCreatesAHostBoundSession(t *testing.T) { | ||
| base := t.TempDir() | ||
| service, err := New(base) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| service.now = func() time.Time { return time.Unix(1_700_000_000, 0) } | ||
| link, err := service.MagicLink("Person@Example.com", "app.example.com", "/private") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| email, host, returnPath, err := service.VerifyMagicLink(link) | ||
| if err != nil || email != "person@example.com" || host != "app.example.com" || returnPath != "/private" { | ||
| t.Fatalf("VerifyMagicLink() = %q, %q, %q, %v", email, host, returnPath, err) | ||
| } | ||
| if _, _, _, err := service.VerifyMagicLink(link); err == nil { | ||
| t.Fatal("magic link was accepted twice") | ||
| } | ||
| restarted, err := New(base) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| restarted.now = service.now | ||
| if _, _, _, err := restarted.VerifyMagicLink(link); err == nil { | ||
| t.Fatal("magic link was accepted after restart") | ||
| } | ||
| session, err := service.Session(email, host, 24) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| policy := &models.DomainAccessConfig{Enabled: true, Mode: "allowlist", AllowedEmails: []string{"person@example.com"}} | ||
| if !service.ValidateSession(session, host, policy) { | ||
| t.Fatal("session was not accepted for its host and policy") | ||
| } | ||
| if service.ValidateSession(session, "other.example.com", policy) { | ||
| t.Fatal("session was accepted for another host") | ||
| } | ||
| } | ||
|
|
||
| func TestAnyVerifiedPolicyRequiresOneValidEmailAddress(t *testing.T) { | ||
| policy := &models.DomainAccessConfig{Enabled: true, Mode: "any_verified"} | ||
| if !Allows(policy, "person@example.com") { | ||
| t.Fatal("valid email was rejected") | ||
| } | ||
| for _, invalid := range []string{"", "person@example.com,other@example.com", "Person <person@example.com>"} { | ||
| if Allows(policy, invalid) { | ||
| t.Fatalf("invalid email %q was accepted", invalid) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestMatchingRouteOnlyAliasDoesNotModifyAliases(t *testing.T) { | ||
| aliases := make([]string, 1, 2) | ||
| aliases[0] = "www.example.com" | ||
| backing := aliases[:2] | ||
| backing[1] = "keep.example.com" | ||
| domain := models.DomainConfig{ | ||
| Domain: "example.com", | ||
| Aliases: aliases, | ||
| RouteOnlyAliases: []string{"internal.example.com"}, | ||
| } | ||
|
|
||
| if !matchesHost(domain, "internal.example.com") { | ||
| t.Fatal("route-only alias did not match") | ||
| } | ||
| if backing[1] != "keep.example.com" { | ||
| t.Fatalf("alias backing array was modified: %q", backing[1]) | ||
| } | ||
| } | ||
|
|
||
| func TestAllowEmailRequestEvictsExpiredEntries(t *testing.T) { | ||
| service, err := New(t.TempDir()) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| now := time.Unix(1_700_000_000, 0) | ||
| service.now = func() time.Time { return now } | ||
| service.AllowEmailRequest("app.example.com", "first@example.com") | ||
| now = now.Add(time.Minute) | ||
| service.AllowEmailRequest("app.example.com", "second@example.com") | ||
| if len(service.lastRequests) != 1 { | ||
| t.Fatalf("rate limit entries = %d", len(service.lastRequests)) | ||
| } | ||
| } | ||
|
|
||
| func TestCanonicalAccessValues(t *testing.T) { | ||
| if Hostname("APP.EXAMPLE.COM.:443") != "app.example.com" { | ||
| t.Fatal("host with port was not normalized") | ||
| } | ||
| if Hostname("APP.EXAMPLE.COM.") != "app.example.com" { | ||
| t.Fatal("trailing dot was not removed") | ||
| } | ||
| if SafeReturn("//other.example.com") != "/" { | ||
| t.Fatal("unsafe return path was accepted") | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lastRequestsis keyed by host+email and is only ever written, never evicted. BecauseAllowEmailRequestis reached for every syntactically valid address inany_verifiedmode, an attacker (or ordinary traffic to distinct addresses) accumulates one map entry per key forever — an unbounded memory growth path. The rate-limit window is one minute, so entries older than that are dead weight and can be dropped opportunistically.