Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions internal/access/service.go
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 = &copy
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastRequests is keyed by host+email and is only ever written, never evicted. Because AllowEmailRequest is reached for every syntactically valid address in any_verified mode, 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.

Suggested change
func (s *Service) AllowEmailRequest(host, email string) bool {
func (s *Service) AllowEmailRequest(host, email string) bool {
key := hostname(host) + "\x00" + normalizeEmail(email)
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
// lastRequests is only ever written, so stale entries (older than the
// one-minute window) would accumulate without bound; drop them once the
// map grows past a small threshold.
if len(s.lastRequests) > 1024 {
for entry, seen := range s.lastRequests {
if now.Sub(seen) >= time.Minute {
delete(s.lastRequests, entry)
}
}
}
if last, ok := s.lastRequests[key]; ok && now.Sub(last) < time.Minute {
return false
}
s.lastRequests[key] = now
return true
}

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),
}
}
120 changes: 120 additions & 0 deletions internal/access/service_test.go
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")
}
}
Loading
Loading