Allow per-app allow/deny.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// accessLists is one scope's worth of authorization rules — either the global
|
||||
// config or a single app's overrides.
|
||||
type accessLists struct {
|
||||
AllowedDomains []string
|
||||
AllowedEmails []string
|
||||
DeniedEmails []string
|
||||
}
|
||||
|
||||
func (a accessLists) hasAllowRules() bool {
|
||||
return len(a.AllowedDomains) > 0 || len(a.AllowedEmails) > 0
|
||||
}
|
||||
|
||||
// appStore reads per-app allow/deny lists from a directory the dokku plugin
|
||||
// bind-mounts read-only (one subdirectory per app, one file per list). Reading
|
||||
// from disk rather than the environment means the plugin can change an app's
|
||||
// lists without restarting this service and interrupting every other app.
|
||||
//
|
||||
// Results are cached for a short TTL because the lists are consulted on every
|
||||
// authenticated request.
|
||||
type appStore struct {
|
||||
dir string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cachedLists
|
||||
}
|
||||
|
||||
type cachedLists struct {
|
||||
lists accessLists
|
||||
loadedAt time.Time
|
||||
}
|
||||
|
||||
func newAppStore(dir string) *appStore {
|
||||
return &appStore{
|
||||
dir: dir,
|
||||
ttl: 2 * time.Second,
|
||||
now: time.Now,
|
||||
cache: map[string]cachedLists{},
|
||||
}
|
||||
}
|
||||
|
||||
// lists returns the app's own rules, or a zero value when the app has none,
|
||||
// the name is unusable, or per-app config is disabled. A zero value means
|
||||
// "inherit the global config", so every failure path is a safe fallback.
|
||||
func (s *appStore) lists(app string) accessLists {
|
||||
if s == nil || s.dir == "" || !validAppName(app) {
|
||||
return accessLists{}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if hit, ok := s.cache[app]; ok && s.now().Sub(hit.loadedAt) < s.ttl {
|
||||
return hit.lists
|
||||
}
|
||||
|
||||
lists := accessLists{
|
||||
AllowedDomains: readDomainFile(filepath.Join(s.dir, app, "allowed-domains")),
|
||||
AllowedEmails: readListFile(filepath.Join(s.dir, app, "allowed-emails")),
|
||||
DeniedEmails: readListFile(filepath.Join(s.dir, app, "denied-emails")),
|
||||
}
|
||||
s.cache[app] = cachedLists{lists: lists, loadedAt: s.now()}
|
||||
return lists
|
||||
}
|
||||
|
||||
func readListFile(path string) []string {
|
||||
// A missing or unreadable file is normal: most apps have no overrides.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return splitList(string(raw))
|
||||
}
|
||||
|
||||
func readDomainFile(path string) []string {
|
||||
entries := readListFile(path)
|
||||
for i, d := range entries {
|
||||
entries[i] = strings.TrimPrefix(d, "@")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// validAppName guards the path built from an nginx-supplied header. dokku app
|
||||
// names are lowercase alphanumerics with dashes, dots, and underscores; this
|
||||
// deliberately rejects anything that could escape the config directory.
|
||||
func validAppName(app string) bool {
|
||||
if app == "" || len(app) > 100 {
|
||||
return false
|
||||
}
|
||||
if app == "." || app == ".." || strings.HasPrefix(app, ".") {
|
||||
return false
|
||||
}
|
||||
for _, r := range app {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package authproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeAppLists lays out one app's config the way the dokku plugin does.
|
||||
func writeAppLists(t *testing.T, dir, app string, files map[string]string) {
|
||||
t.Helper()
|
||||
appDir := filepath.Join(dir, app)
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(appDir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppStoreReadsLists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAppLists(t, dir, "my-app", map[string]string{
|
||||
"allowed-domains": "Signal.org\n@example.com\n",
|
||||
"allowed-emails": "Guest@Partner.com\n",
|
||||
"denied-emails": "former@signal.org\n",
|
||||
})
|
||||
store := newAppStore(dir)
|
||||
|
||||
got := store.lists("my-app")
|
||||
if len(got.AllowedDomains) != 2 || got.AllowedDomains[0] != "signal.org" || got.AllowedDomains[1] != "example.com" {
|
||||
t.Errorf("AllowedDomains = %v", got.AllowedDomains)
|
||||
}
|
||||
if len(got.AllowedEmails) != 1 || got.AllowedEmails[0] != "guest@partner.com" {
|
||||
t.Errorf("AllowedEmails = %v", got.AllowedEmails)
|
||||
}
|
||||
if len(got.DeniedEmails) != 1 || got.DeniedEmails[0] != "former@signal.org" {
|
||||
t.Errorf("DeniedEmails = %v", got.DeniedEmails)
|
||||
}
|
||||
|
||||
// An app with no directory, and a disabled store, both mean "inherit".
|
||||
if store.lists("other-app").hasAllowRules() {
|
||||
t.Error("an app with no config should have no allow rules")
|
||||
}
|
||||
if newAppStore("").lists("my-app").hasAllowRules() {
|
||||
t.Error("an empty config dir should disable per-app lists")
|
||||
}
|
||||
}
|
||||
|
||||
// The app name reaches the service in a header, so it must never be able to
|
||||
// walk out of the config directory.
|
||||
func TestAppStoreRejectsUnusableNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAppLists(t, dir, "escaped", map[string]string{"allowed-emails": "attacker@evil.com\n"})
|
||||
store := newAppStore(dir)
|
||||
|
||||
for _, name := range []string{
|
||||
"", ".", "..", "../escaped", "..%2fescaped", "/escaped", "sub/escaped",
|
||||
".hidden", "app name", "app;rm", "app\x00", "app\n",
|
||||
} {
|
||||
if got := store.lists(name); got.hasAllowRules() || len(got.DeniedEmails) > 0 {
|
||||
t.Errorf("lists(%q) returned rules; want empty", name)
|
||||
}
|
||||
if validAppName(name) {
|
||||
t.Errorf("validAppName(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"my-app", "my_app", "app.example", "App2"} {
|
||||
if !validAppName(name) {
|
||||
t.Errorf("validAppName(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppStoreCachesUntilTTL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeAppLists(t, dir, "my-app", map[string]string{"allowed-emails": "first@signal.org\n"})
|
||||
|
||||
now := time.Unix(1700000000, 0)
|
||||
store := newAppStore(dir)
|
||||
store.now = func() time.Time { return now }
|
||||
|
||||
if got := store.lists("my-app").AllowedEmails[0]; got != "first@signal.org" {
|
||||
t.Fatalf("first read = %q", got)
|
||||
}
|
||||
writeAppLists(t, dir, "my-app", map[string]string{"allowed-emails": "second@signal.org\n"})
|
||||
if got := store.lists("my-app").AllowedEmails[0]; got != "first@signal.org" {
|
||||
t.Errorf("within the TTL the cached value should stand, got %q", got)
|
||||
}
|
||||
now = now.Add(store.ttl + time.Second)
|
||||
if got := store.lists("my-app").AllowedEmails[0]; got != "second@signal.org" {
|
||||
t.Errorf("after the TTL the change should be picked up, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newPerAppServer is a server whose global list allows all of signal.org, plus
|
||||
// per-app overrides on disk.
|
||||
func newPerAppServer(t *testing.T, apps map[string]map[string]string) *Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for app, files := range apps {
|
||||
writeAppLists(t, dir, app, files)
|
||||
}
|
||||
s := newTestServer(t, "http://unused.invalid")
|
||||
s.cfg.AppConfigDir = dir
|
||||
s.apps = newAppStore(dir)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestPerAppAllowReplacesGlobal(t *testing.T) {
|
||||
s := newPerAppServer(t, map[string]map[string]string{
|
||||
"narrowed": {"allowed-emails": "ceo@signal.org\n"},
|
||||
"widened": {"allowed-emails": "guest@partner.com\n"},
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
app, email string
|
||||
want bool
|
||||
}{
|
||||
// No per-app config: the global list applies.
|
||||
{"inherits", "anyone@signal.org", true},
|
||||
{"inherits", "guest@partner.com", false},
|
||||
{"", "anyone@signal.org", true},
|
||||
// Own allow list: it replaces the global one entirely.
|
||||
{"narrowed", "ceo@signal.org", true},
|
||||
{"narrowed", "anyone@signal.org", false},
|
||||
// An app may admit someone the global list does not cover.
|
||||
{"widened", "guest@partner.com", true},
|
||||
{"widened", "anyone@signal.org", false},
|
||||
// An unknown app name falls back to global rather than failing open.
|
||||
{"no-such-app", "anyone@signal.org", true},
|
||||
{"../narrowed", "anyone@signal.org", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := s.emailAllowedFor(c.app, c.email); got != c.want {
|
||||
t.Errorf("emailAllowedFor(%q, %q) = %v, want %v", c.app, c.email, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerAppDenyCombinesWithGlobal(t *testing.T) {
|
||||
s := newPerAppServer(t, map[string]map[string]string{
|
||||
"app-a": {"denied-emails": "bob@signal.org\n"},
|
||||
// An app's own deny list must not resurrect a globally denied account.
|
||||
"app-b": {"denied-emails": "someone@signal.org\n"},
|
||||
// Nor may an app's allow list override a global denial.
|
||||
"app-c": {"allowed-emails": "gone@signal.org\n"},
|
||||
})
|
||||
s.cfg.DeniedEmails = []string{"gone@signal.org"}
|
||||
|
||||
cases := []struct {
|
||||
app, email string
|
||||
want bool
|
||||
}{
|
||||
{"app-a", "bob@signal.org", false}, // denied for this app
|
||||
{"app-b", "bob@signal.org", true}, // ...but not for another
|
||||
{"app-a", "gone@signal.org", false}, // global denial still applies
|
||||
{"app-b", "gone@signal.org", false}, // even with its own deny list
|
||||
{"app-c", "gone@signal.org", false}, // even when the app allows them
|
||||
{"inherits", "gone@signal.org", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := s.emailAllowedFor(c.app, c.email); got != c.want {
|
||||
t.Errorf("emailAllowedFor(%q, %q) = %v, want %v", c.app, c.email, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The app name is read from a header, so verify must use the value nginx set
|
||||
// and a session must not be portable to an app with stricter rules.
|
||||
func TestVerifyUsesAppHeader(t *testing.T) {
|
||||
s := newPerAppServer(t, map[string]map[string]string{
|
||||
"strict": {"allowed-emails": "ceo@signal.org\n"},
|
||||
})
|
||||
sess := sessionClaims{Email: "dev@signal.org", User: "1", Host: appHost,
|
||||
Exp: time.Now().Add(time.Hour).Unix()}
|
||||
val, err := s.box.seal("session", sess)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verify := func(app string) int {
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "_google_auth", Value: val})
|
||||
if app != "" {
|
||||
r.Header.Set(AppHeader, app)
|
||||
}
|
||||
return do(s.Routes(), r).Code
|
||||
}
|
||||
|
||||
if got := verify("lenient"); got != http.StatusOK {
|
||||
t.Errorf("app inheriting the global list: got %d, want 200", got)
|
||||
}
|
||||
if got := verify("strict"); got != http.StatusUnauthorized {
|
||||
t.Errorf("app whose list excludes the user: got %d, want 401", got)
|
||||
}
|
||||
if got := verify(""); got != http.StatusOK {
|
||||
t.Errorf("no app header: got %d, want 200 (global rules)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The callback runs on the auth host, which may serve a different app than the
|
||||
// one being signed in to. Authorization must follow the destination app.
|
||||
func TestCallbackAuthorizesAgainstDestinationApp(t *testing.T) {
|
||||
google := fakeGoogle(t, goodClaims()) // signs in greyson@signal.org
|
||||
defer google.Close()
|
||||
s := newPerAppServer(t, map[string]map[string]string{
|
||||
"strict": {"allowed-emails": "ceo@signal.org\n"},
|
||||
})
|
||||
s.cfg.TokenURL = google.URL
|
||||
|
||||
callback := func(app string) *httptest.ResponseRecorder {
|
||||
tok, err := s.box.seal("state", stateClaims{
|
||||
Host: appHost, App: app, RD: "/", Proto: "https", Nonce: randToken(),
|
||||
Exp: time.Now().Add(10 * time.Minute).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET",
|
||||
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(tok), nil)
|
||||
// The auth host's own app name, which must not be what decides.
|
||||
r.Header.Set(AppHeader, "auth-host-app")
|
||||
return do(s.Routes(), r)
|
||||
}
|
||||
|
||||
if w := callback("lenient"); w.Code != http.StatusFound {
|
||||
t.Errorf("destination inheriting the global list: got %d, want 302; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := callback("strict"); w.Code != http.StatusForbidden {
|
||||
t.Errorf("destination whose list excludes the user: got %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The hd hint should follow the app's effective domain list, not the global one.
|
||||
func TestStartHintsAppDomain(t *testing.T) {
|
||||
s := newPerAppServer(t, map[string]map[string]string{
|
||||
"partner-app": {"allowed-domains": "partner.com\n"},
|
||||
})
|
||||
start := func(app string) string {
|
||||
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/start", nil)
|
||||
r.Header.Set("Accept", "text/html")
|
||||
r.Header.Set(AppHeader, app)
|
||||
loc, err := url.Parse(do(s.Routes(), r).Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc.Query().Get("hd")
|
||||
}
|
||||
if got := start("inherits"); got != "signal.org" {
|
||||
t.Errorf("hd for an inheriting app = %q, want signal.org", got)
|
||||
}
|
||||
if got := start("partner-app"); got != "partner.com" {
|
||||
t.Errorf("hd for an app with its own domain = %q, want partner.com", got)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@ package authproxy
|
||||
// It remembers which app host the user was visiting and where to send them
|
||||
// after sign-in.
|
||||
type stateClaims struct {
|
||||
Host string `json:"h"` // app host the user was visiting
|
||||
Host string `json:"h"` // app host the user was visiting
|
||||
// App is the dokku app that host belongs to. It rides through the flow
|
||||
// because the callback runs on the auth host, which may be a different
|
||||
// app than the one being signed in to — and it is that destination app's
|
||||
// access lists that decide.
|
||||
App string `json:"a"`
|
||||
RD string `json:"r"` // relative path to return to
|
||||
Proto string `json:"p"` // http or https
|
||||
Nonce string `json:"n"`
|
||||
|
||||
@@ -14,6 +14,11 @@ const (
|
||||
// RoutePrefix is the URL namespace the proxy owns on every protected
|
||||
// host. It must match the locations the plugin writes into nginx.
|
||||
RoutePrefix = "/_google-auth"
|
||||
|
||||
// AppHeader carries the dokku app name a request belongs to. Every nginx
|
||||
// location that reaches this service sets it explicitly, which also
|
||||
// overwrites anything a client tried to send.
|
||||
AppHeader = "X-Google-Auth-App"
|
||||
)
|
||||
|
||||
// Config holds everything the auth service needs. It is normally populated
|
||||
@@ -44,6 +49,12 @@ type Config struct {
|
||||
// whole domain it belongs to.
|
||||
DeniedEmails []string
|
||||
|
||||
// AppConfigDir holds one subdirectory per app with that app's own
|
||||
// allow/deny lists, bind-mounted read-only by the plugin. An app's allow
|
||||
// rules replace the global ones; deny lists are combined. Empty disables
|
||||
// per-app config, leaving the global lists in charge.
|
||||
AppConfigDir string
|
||||
|
||||
CookieName string
|
||||
SessionTTL time.Duration
|
||||
ListenAddr string
|
||||
@@ -68,6 +79,7 @@ func ConfigFromEnv() (Config, error) {
|
||||
AuthHost: normalizeHost(os.Getenv("GOOGLE_AUTH_AUTH_HOST")),
|
||||
CookieName: envOr("GOOGLE_AUTH_COOKIE_NAME", "_google_auth"),
|
||||
ListenAddr: envOr("GOOGLE_AUTH_LISTEN", ":2999"),
|
||||
AppConfigDir: os.Getenv("GOOGLE_AUTH_APP_CONFIG_DIR"),
|
||||
AuthorizeURL: envOr("GOOGLE_AUTH_AUTHORIZE_URL", googleAuthorizeURL),
|
||||
TokenURL: envOr("GOOGLE_AUTH_TOKEN_URL", googleTokenURL),
|
||||
AllowInsecure: os.Getenv("GOOGLE_AUTH_ALLOW_INSECURE") == "true",
|
||||
|
||||
@@ -354,8 +354,8 @@ func TestEmailAllowed(t *testing.T) {
|
||||
"signal.org": false,
|
||||
}
|
||||
for email, want := range cases {
|
||||
if got := s.emailAllowed(email); got != want {
|
||||
t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want)
|
||||
if got := s.emailAllowedFor("", email); got != want {
|
||||
t.Errorf("emailAllowedFor(%q) = %v, want %v", email, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,8 +373,8 @@ func TestEmailDenied(t *testing.T) {
|
||||
"guest@partner.com": false,
|
||||
}
|
||||
for email, want := range cases {
|
||||
if got := s.emailAllowed(email); got != want {
|
||||
t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want)
|
||||
if got := s.emailAllowedFor("", email); got != want {
|
||||
t.Errorf("emailAllowedFor(%q) = %v, want %v", email, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type Server struct {
|
||||
cfg Config
|
||||
box *box
|
||||
nonces *nonceCache
|
||||
apps *appStore
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ func New(cfg Config) (*Server, error) {
|
||||
cfg: cfg,
|
||||
box: b,
|
||||
nonces: newNonceCache(),
|
||||
apps: newAppStore(cfg.AppConfigDir),
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
@@ -96,8 +98,10 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
app := appFromRequest(r)
|
||||
st := stateClaims{
|
||||
Host: host,
|
||||
App: app,
|
||||
RD: rd,
|
||||
Proto: s.proto(r),
|
||||
Nonce: randToken(),
|
||||
@@ -115,9 +119,9 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "openid email profile")
|
||||
q.Set("state", token)
|
||||
if len(s.cfg.AllowedDomains) == 1 {
|
||||
// UX hint only; real enforcement happens in emailAllowed.
|
||||
q.Set("hd", s.cfg.AllowedDomains[0])
|
||||
if domains := s.effectiveLists(app).AllowedDomains; len(domains) == 1 {
|
||||
// UX hint only; real enforcement happens in emailAllowedFor.
|
||||
q.Set("hd", domains[0])
|
||||
}
|
||||
http.Redirect(w, r, s.cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
@@ -157,9 +161,11 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.htmlError(w, http.StatusForbidden, "Google returned an invalid identity token.")
|
||||
return
|
||||
}
|
||||
// Authorize against the app the user is signing in to, not the app serving
|
||||
// this callback — those differ whenever the auth host belongs elsewhere.
|
||||
email := strings.ToLower(idTok.Email)
|
||||
if !s.emailAllowed(email) {
|
||||
log.Printf("callback: denied %s (not in allowed domains/emails) for host %s", email, st.Host)
|
||||
if !s.emailAllowedFor(st.App, email) {
|
||||
log.Printf("callback: denied %s for app %q on host %s (not allowed)", email, st.App, st.Host)
|
||||
s.htmlError(w, http.StatusForbidden,
|
||||
"You are signed in to Google as <b>"+html.EscapeString(email)+"</b>, but that account is not allowed to access this app.")
|
||||
return
|
||||
@@ -288,28 +294,64 @@ func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) {
|
||||
}
|
||||
// Re-check authorization on every request so allow/deny list changes take
|
||||
// effect immediately instead of whenever existing sessions happen to
|
||||
// expire.
|
||||
if !s.emailAllowed(sess.Email) {
|
||||
// expire. This is also what keeps a session minted for one app from being
|
||||
// accepted by an app with stricter lists.
|
||||
if !s.emailAllowedFor(appFromRequest(r), sess.Email) {
|
||||
return nil, false
|
||||
}
|
||||
return &sess, true
|
||||
}
|
||||
|
||||
// emailAllowed applies the denylist first (it always wins), then the
|
||||
// allowlist: an address must match an allowed email or an allowed domain.
|
||||
func (s *Server) emailAllowed(email string) bool {
|
||||
// effectiveLists resolves the rules that apply to one app: the app's own allow
|
||||
// rules replace the global ones when it has any (so an app can be narrowed to a
|
||||
// few people, or opened to an outside collaborator, independently of the
|
||||
// global list), while deny lists are combined so a global denial can never be
|
||||
// undone by an app's config.
|
||||
func (s *Server) effectiveLists(app string) accessLists {
|
||||
own := s.apps.lists(app)
|
||||
out := accessLists{
|
||||
AllowedDomains: s.cfg.AllowedDomains,
|
||||
AllowedEmails: s.cfg.AllowedEmails,
|
||||
DeniedEmails: s.cfg.DeniedEmails,
|
||||
}
|
||||
if own.hasAllowRules() {
|
||||
out.AllowedDomains = own.AllowedDomains
|
||||
out.AllowedEmails = own.AllowedEmails
|
||||
}
|
||||
if len(own.DeniedEmails) > 0 {
|
||||
out.DeniedEmails = append(append([]string{}, out.DeniedEmails...), own.DeniedEmails...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emailAllowedFor applies app's rules: the deny list first (it always wins),
|
||||
// then the allow list, where an address must match an allowed email or an
|
||||
// allowed domain. An empty app means "global rules only".
|
||||
func (s *Server) emailAllowedFor(app, email string) bool {
|
||||
email = strings.ToLower(email)
|
||||
if slices.Contains(s.cfg.DeniedEmails, email) {
|
||||
lists := s.effectiveLists(app)
|
||||
if slices.Contains(lists.DeniedEmails, email) {
|
||||
return false
|
||||
}
|
||||
if slices.Contains(s.cfg.AllowedEmails, email) {
|
||||
if slices.Contains(lists.AllowedEmails, email) {
|
||||
return true
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(s.cfg.AllowedDomains, email[at+1:])
|
||||
return slices.Contains(lists.AllowedDomains, email[at+1:])
|
||||
}
|
||||
|
||||
// appFromRequest reads the app name nginx stamps on every request that reaches
|
||||
// this service. Clients cannot influence it: each generated location sets the
|
||||
// header explicitly, replacing whatever arrived from outside.
|
||||
func appFromRequest(r *http.Request) string {
|
||||
app := strings.TrimSpace(r.Header.Get(AppHeader))
|
||||
if !validAppName(app) {
|
||||
return ""
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func (s *Server) redirectURI() string {
|
||||
|
||||
Reference in New Issue
Block a user