package config import ( "fmt" "net" "net/url" "path/filepath" "regexp" "slices" "strconv" "strings" "time" "tug/internal/platform/userbasekeys" ) var ( groupPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) clientIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) labelPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) numericPattern = regexp.MustCompile(`^[0-9]+$`) portPattern = regexp.MustCompile(`^[1-9][0-9]{0,4}$`) ) const sshPrefix = "ssh://" var unsafeRedirectSchemes = map[string]bool{"javascript": true, "data": true, "vbscript": true, "file": true} type validator struct { file string idx *keyIndex problems []Problem } func (v *validator) lineFor(key string) int { for k := key; k != ""; { if line := v.idx.line(k); line > 0 { return line } i := strings.LastIndexAny(k, ".[") if i < 0 { break } k = k[:i] } return 0 } func (v *validator) add(key, reason string) { v.problems = append(v.problems, Problem{File: v.file, Line: v.lineFor(key), Key: key, Reason: reason}) } func (v *validator) required(key, value string) bool { if !v.idx.has(key) { v.add(key, reasonRequired) return false } if value == "" { v.add(key, reasonEmpty) return false } return true } func (v *validator) duration(key, value string, def time.Duration) time.Duration { if !v.idx.has(key) { return def } d, err := time.ParseDuration(value) if err != nil { v.add(key, reasonDuration) return 0 } if d <= 0 { v.add(key, reasonDurationPositive) return 0 } return d } func (v *validator) requiredDuration(key, value string) time.Duration { if !v.idx.has(key) { v.add(key, reasonRequired) return 0 } return v.duration(key, value, 0) } func (v *validator) group(key, value string) { if !groupPattern.MatchString(value) { v.add(key, reasonGroupName) } } func (v *validator) baseDN(key, value string) { for _, rdn := range strings.Split(value, ",") { attr, label, ok := strings.Cut(rdn, "=") if !ok || (attr != "dc" && attr != "o" && attr != "ou") || !labelPattern.MatchString(label) { v.add(key, reasonBaseDN) return } } } func (v *validator) absolutePath(key, value string) { if !filepath.IsAbs(value) || filepath.Clean(value) != value || value == "/" { v.add(key, reasonAbsolutePath) } } func (v *validator) hostname(key, value string) { if len(value) > 253 || strings.HasSuffix(value, ".") { v.add(key, reasonHostname) return } labels := strings.Split(value, ".") if len(labels) < 2 || net.ParseIP(value) != nil || numericPattern.MatchString(labels[len(labels)-1]) { v.add(key, reasonHostname) return } for _, l := range labels { if !labelPattern.MatchString(l) { v.add(key, reasonHostname) return } } } func (v *validator) address(key, value string) (net.IP, string) { host, portText, err := net.SplitHostPort(value) if err != nil { v.add(key, reasonHostPort) return nil, "" } ip := net.ParseIP(host) if ip == nil { v.add(key, reasonHostPort) return nil, "" } port, err := strconv.Atoi(portText) if !portPattern.MatchString(portText) || err != nil || port > 65535 { v.add(key, reasonPort) return nil, "" } return ip, net.JoinHostPort(ip.String(), strconv.Itoa(port)) } type keyed struct { key, value string } func (v *validator) app(prefix string, a rawApp, sessionTTL time.Duration) App { if v.required(prefix+".domain", a.Domain) { v.hostname(prefix+".domain", a.Domain) } seen := map[string]bool{} for i, g := range a.Groups { key := fmt.Sprintf("%s.groups[%d]", prefix, i) v.group(key, g) if seen[g] { v.add(key, reasonGroupDuplicate) } seen[g] = true } return App{Domain: a.Domain, SessionTTL: v.duration(prefix+".session_ttl", a.SessionTTL, sessionTTL), Groups: append([]string(nil), a.Groups...)} } func (v *validator) distinct(items []keyed, reason string) { seen := map[string]string{} for _, it := range items { if it.value == "" { continue } if first, ok := seen[it.value]; ok { if v.idx.has(it.key) || !v.idx.has(first) { v.add(it.key, reason) } else { v.add(first, reason) } continue } seen[it.value] = it.key } } func usesSSH(remotes ...string) bool { for _, r := range remotes { if strings.HasPrefix(r, sshPrefix) { return true } } return false } func (v *validator) remote(key, value string) bool { if strings.HasPrefix(value, "/") { if !filepath.IsAbs(value) || filepath.Clean(value) != value || value == "/" { v.add(key, reasonRemote) return false } return true } u, err := url.Parse(value) ok := err == nil && strings.HasPrefix(value, sshPrefix) && u.Opaque == "" && u.Hostname() != "" && u.Path != "" && u.Path != "/" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && !strings.ContainsAny(value, "?#") if ok { _, hasPassword := u.User.Password() ok = u.User.Username() != "" && !hasPassword } if ok && u.Port() != "" { n, err := strconv.Atoi(u.Port()) ok = portPattern.MatchString(u.Port()) && err == nil && n <= 65535 } if !ok { v.add(key, reasonRemote) } return ok } func normalizeRemote(remote string) string { remote = strings.TrimSuffix(remote, "/") u, err := url.Parse(remote) if err != nil || u.Scheme != "ssh" || u.Host == "" { return remote } host := strings.ToLower(u.Hostname()) if port := u.Port(); port != "" && port != "22" { host = net.JoinHostPort(host, port) } u.Host = host return u.String() } func redirectURIValid(uri string) bool { u, err := url.Parse(uri) switch { case err != nil, u.Scheme == "", u.Fragment != "", strings.Contains(uri, "#"), unsafeRedirectSchemes[strings.ToLower(u.Scheme)]: return false case u.Opaque != "": return strings.EqualFold(u.Scheme, "urn") case u.Scheme == "http" || u.Scheme == "https": if u.Hostname() == "" { return false } if p := u.Port(); p != "" { n, err := strconv.Atoi(p) return portPattern.MatchString(p) && err == nil && n <= 65535 } } return true } func validate(file string, idx *keyIndex, raw *rawFile) (*Config, []Problem) { v := &validator{file: file, idx: idx} cfg := &Config{} var listeners []keyed s := raw.Server socketSet, listenSet := idx.has("server.socket"), idx.has("server.listen") switch { case socketSet && listenSet: v.add("server.listen", reasonSocketXorListen) case !socketSet && !listenSet: v.add("server.socket", reasonSocketXorListen) } if socketSet { v.absolutePath("server.socket", s.Socket) cfg.Server.Socket = s.Socket } if listenSet { if ip, addr := v.address("server.listen", s.Listen); ip != nil { if !ip.IsLoopback() { v.add("server.listen", reasonLoopbackOnly) } listeners = append(listeners, keyed{"server.listen", addr}) cfg.Server.Listen = s.Listen } } metrics := defaultMetricsListen if idx.has("server.metrics_listen") { metrics = s.MetricsListen } if ip, addr := v.address("server.metrics_listen", metrics); ip != nil { switch { case ip.IsUnspecified(): v.add("server.metrics_listen", reasonUnspecifiedAddress) case ip.IsMulticast(): v.add("server.metrics_listen", reasonMulticastAddress) } listeners = append(listeners, keyed{"server.metrics_listen", addr}) } cfg.Server.MetricsListen = metrics domains := []keyed{ {"server.domain_idp", s.DomainIDP}, {"server.domain_account", s.DomainAccount}, {"server.domain_admin", s.DomainAdmin}, } for _, d := range domains { if v.required(d.key, d.value) { v.hostname(d.key, d.value) } } cfg.Server.DomainIDP, cfg.Server.DomainAccount, cfg.Server.DomainAdmin = s.DomainIDP, s.DomainAccount, s.DomainAdmin cfg.Server.AdminGroup = defaultAdminGroup if idx.has("server.admin_group") { v.group("server.admin_group", s.AdminGroup) cfg.Server.AdminGroup = s.AdminGroup } cfg.Server.SiteName = v.siteName(s.SiteName) if idx.has("portdistrict") { pd := raw.Portdistrict if pd == nil { pd = &rawPortdistrict{} } out := &Portdistrict{Listen: pd.Listen, PeerTable: pd.PeerTable} if v.required("portdistrict.listen", pd.Listen) { if ip, addr := v.address("portdistrict.listen", pd.Listen); ip != nil { switch { case ip.IsUnspecified(): v.add("portdistrict.listen", reasonUnspecifiedAddress) case ip.IsLoopback(): v.add("portdistrict.listen", reasonLoopbackAddress) case ip.IsMulticast(): v.add("portdistrict.listen", reasonMulticastAddress) } listeners = append(listeners, keyed{"portdistrict.listen", addr}) } } if v.required("portdistrict.peer_table", pd.PeerTable) { v.absolutePath("portdistrict.peer_table", pd.PeerTable) } out.TableMaxAge = v.requiredDuration("portdistrict.table_max_age", pd.TableMaxAge) cfg.Portdistrict = out } cfg.Source = sourceFile if idx.has("config.source") { switch raw.Config.Source { case sourceFile, sourceGit: default: v.add("config.source", reasonSourceValue) } cfg.Source = raw.Config.Source } var repos []keyed if idx.has("config.git") { cfg.ConfigGit = v.configGit(raw.Config.Git) repos = append(repos, keyed{"config.git.remote", normalizeRemote(cfg.ConfigGit.Remote)}) } else if cfg.Source == sourceGit { v.add("config.git", reasonRequired) } if idx.has("userbase") { u := raw.Userbase if u == nil { u = &rawUserbase{} } remotesValid := v.required("userbase.repo", u.Repo) && v.remote("userbase.repo", u.Repo) if idx.has("userbase.mirror") { if u.Mirror == "" { v.add("userbase.mirror", reasonEmpty) } else { remotesValid = v.remote("userbase.mirror", u.Mirror) && remotesValid } } switch { case !remotesValid: case idx.has("userbase.known_hosts") && !usesSSH(u.Repo, u.Mirror): v.add("userbase.known_hosts", reasonKnownHostsUnused) case idx.has("userbase.known_hosts"): v.absolutePath("userbase.known_hosts", u.KnownHosts) case usesSSH(u.Repo, u.Mirror): v.add("userbase.known_hosts", reasonRequired) } if v.required("userbase.break_glass_recipient", u.BreakGlassRecipient) { if _, err := userbasekeys.ParseRecipient(u.BreakGlassRecipient); err != nil { v.add("userbase.break_glass_recipient", reasonAgeRecipient) } } repos = append(repos, keyed{"userbase.repo", normalizeRemote(u.Repo)}, keyed{"userbase.mirror", normalizeRemote(u.Mirror)}) cfg.Userbase = &Userbase{Repo: u.Repo, Mirror: u.Mirror, KnownHosts: u.KnownHosts, BreakGlassRecipient: u.BreakGlassRecipient, ExportSigningKeys: u.ExportSigningKeys, RestoreOnFirstBoot: u.RestoreOnFirstBoot} } v.distinct(repos, reasonReposDistinct) cfg.Invite.TTL = v.duration("invite.ttl", raw.Invite.TTL, defaultInviteTTL) cfg.Invite.DefaultGroups = append([]string(nil), defaultInviteGroups...) if idx.has("invite.default_groups") { cfg.Invite.DefaultGroups = append([]string{}, raw.Invite.DefaultGroups...) seen := map[string]bool{} for i, g := range raw.Invite.DefaultGroups { key := fmt.Sprintf("invite.default_groups[%d]", i) v.group(key, g) if seen[g] { v.add(key, reasonGroupDuplicate) } seen[g] = true } } cfg.Session.TTL = v.duration("session.ttl", raw.Session.TTL, defaultSessionTTL) cfg.Audit = v.audit(raw.Audit) if idx.has("google") { g := raw.Google if g == nil { g = &rawGoogle{} } v.required("google.client_id", g.ClientID) cfg.Google = &Google{ClientID: g.ClientID, AllowedHD: g.AllowedHD} } cfg.PrimaryGroup = defaultPrimaryGroup if idx.has("ldap") { l := raw.LDAP if l == nil { l = &rawLDAP{} } if v.required("ldap.listen", l.Listen) { if ip, addr := v.address("ldap.listen", l.Listen); ip != nil { switch { case ip.IsUnspecified(): v.add("ldap.listen", reasonUnspecifiedAddress) case ip.IsMulticast(): v.add("ldap.listen", reasonMulticastAddress) } listeners = append(listeners, keyed{"ldap.listen", addr}) } } if idx.has("ldap.primary_group") { v.group("ldap.primary_group", l.PrimaryGroup) cfg.PrimaryGroup = l.PrimaryGroup } if v.required("ldap.base_dn", l.BaseDN) { v.baseDN("ldap.base_dn", l.BaseDN) } certSet, keySet := idx.has("ldap.tls_cert"), idx.has("ldap.tls_key") switch { case certSet && !keySet: v.add("ldap.tls_key", reasonTLSPair) case keySet && !certSet: v.add("ldap.tls_cert", reasonTLSPair) } if certSet { v.absolutePath("ldap.tls_cert", l.TLSCert) } if keySet { v.absolutePath("ldap.tls_key", l.TLSKey) } cfg.LDAP = &LDAP{Listen: l.Listen, BaseDN: l.BaseDN, TLSCert: l.TLSCert, TLSKey: l.TLSKey} } if cfg.Server.AdminGroup == cfg.PrimaryGroup { if !idx.has("server.admin_group") && idx.has("ldap.primary_group") { v.add("ldap.primary_group", reasonAdminIsPrimary) } else { v.add("server.admin_group", reasonAdminIsPrimary) } } else if i := slices.Index(cfg.Invite.DefaultGroups, cfg.Server.AdminGroup); i >= 0 { if idx.has("invite.default_groups") { v.add(fmt.Sprintf("invite.default_groups[%d]", i), reasonAdminIsDefaultGroup) } else { v.add("server.admin_group", reasonAdminIsDefaultGroup) } } ids := map[string]bool{} for i, c := range raw.Client { prefix := fmt.Sprintf("client[%d]", i) if v.required(prefix+".id", c.ID) { if !clientIDPattern.MatchString(c.ID) { v.add(prefix+".id", reasonClientID) } else if ids[c.ID] { v.add(prefix+".id", reasonClientIDDuplicate) } ids[c.ID] = true } if !c.Public && len(c.RedirectURIs) == 0 { v.add(prefix+".redirect_uris", reasonRedirectRequired) } uris := map[string]bool{} for j, uri := range c.RedirectURIs { key := fmt.Sprintf("%s.redirect_uris[%d]", prefix, j) if !redirectURIValid(uri) { v.add(key, reasonRedirectURI) } if uris[uri] { v.add(key, reasonRedirectDuplicate) } uris[uri] = true } cfg.Clients = append(cfg.Clients, Client{ID: c.ID, RedirectURIs: append([]string(nil), c.RedirectURIs...), Public: c.Public}) } for i, a := range raw.App { prefix := fmt.Sprintf("app[%d]", i) domains = append(domains, keyed{prefix + ".domain", a.Domain}) cfg.Apps = append(cfg.Apps, v.app(prefix, a, cfg.Session.TTL)) } v.distinct(domains, reasonDomainReused) v.distinct(listeners, reasonListenerReused) return cfg, v.problems }