Add check endpoint which can be used with nginx' auth_request function (#266)
* Add check endpoint which can be used with nginx' auth_request function * feat(cmd): allow configuring redirect domains * test: add test environment for the nginx_auth PR This is a full local setup of the nginx_auth PR including HTTPS so that it's easier to validate in isolation. This requires an install of k3s (https://k3s.io) with traefik set to listen on localhost. This will be amended in the future but for now this works enough to ship it. Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(cmd|lib): allow empty redirect domains variable Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(test): add space to target variable in anubis container Signed-off-by: Xe Iaso <me@xeiaso.net> * docs(admin): rewrite subrequest auth docs, make generic * docs(install): document REDIRECT_DOMAINS flag Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(lib): clamp redirects to the same HTTP host Only if REDIRECT_DOMAINS is not set. Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net> Co-authored-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
parent
a5d796c679
commit
6858f66a62
20 changed files with 610 additions and 52 deletions
|
|
@ -14,6 +14,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -64,10 +65,11 @@ var (
|
|||
)
|
||||
|
||||
type Options struct {
|
||||
Next http.Handler
|
||||
Policy *policy.ParsedConfig
|
||||
ServeRobotsTXT bool
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Next http.Handler
|
||||
Policy *policy.ParsedConfig
|
||||
RedirectDomains []string
|
||||
ServeRobotsTXT bool
|
||||
PrivateKey ed25519.PrivateKey
|
||||
|
||||
CookieDomain string
|
||||
CookieName string
|
||||
|
|
@ -148,9 +150,10 @@ func New(opts Options) (*Server, error) {
|
|||
|
||||
mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", result.MakeChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", result.PassChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/check", result.maybeReverseProxyHttpStatusOnly)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", result.TestError)
|
||||
|
||||
mux.HandleFunc("/", result.MaybeReverseProxy)
|
||||
mux.HandleFunc("/", result.maybeReverseProxyOrPage)
|
||||
|
||||
result.mux = mux
|
||||
|
||||
|
|
@ -172,6 +175,36 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
if s.next == nil {
|
||||
redir := r.FormValue("redir")
|
||||
urlParsed, err := r.URL.Parse(redir)
|
||||
if err != nil {
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect URL not parseable", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) {
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
} else if urlParsed.Host != r.URL.Host {
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if redir != "" {
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templ.Handler(
|
||||
web.Base("You are not a bot!", web.StaticHappy()),
|
||||
).ServeHTTP(w, r)
|
||||
} else {
|
||||
s.next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
|
||||
fp := sha256.Sum256(s.priv.Seed())
|
||||
|
||||
|
|
@ -187,7 +220,15 @@ func (s *Server) challengeFor(r *http.Request, difficulty int) string {
|
|||
return internal.SHA256sum(challengeData)
|
||||
}
|
||||
|
||||
func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) maybeReverseProxyHttpStatusOnly(w http.ResponseWriter, r *http.Request) {
|
||||
s.maybeReverseProxy(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) maybeReverseProxyOrPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.maybeReverseProxy(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpStatusOnly bool) {
|
||||
lg := slog.With(
|
||||
"user_agent", r.UserAgent(),
|
||||
"accept_language", r.Header.Get("Accept-Language"),
|
||||
|
|
@ -233,7 +274,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
|||
switch cr.Rule {
|
||||
case config.RuleAllow:
|
||||
lg.Debug("allowing traffic to origin (explicit)")
|
||||
s.next.ServeHTTP(w, r)
|
||||
s.ServeHTTPNext(w, r)
|
||||
return
|
||||
case config.RuleDeny:
|
||||
s.ClearCookie(w)
|
||||
|
|
@ -264,21 +305,21 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil {
|
||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ckie.Valid(); err != nil {
|
||||
lg.Debug("cookie is invalid", "err", err)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -289,14 +330,14 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
|||
if err != nil || !token.Valid {
|
||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
|
||||
if randomJitter() {
|
||||
r.Header.Add("X-Anubis-Status", "PASS-BRIEF")
|
||||
lg.Debug("cookie is not enrolled into secondary screening")
|
||||
s.next.ServeHTTP(w, r)
|
||||
s.ServeHTTPNext(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -304,7 +345,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
lg.Debug("invalid token claims type", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
|
@ -312,7 +353,7 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
|||
if claims["challenge"] != challenge {
|
||||
lg.Debug("invalid challenge", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -329,16 +370,22 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
|||
lg.Debug("invalid response", "path", r.URL.Path)
|
||||
failedValidations.Inc()
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r, rule)
|
||||
s.RenderIndex(w, r, rule, httpStatusOnly)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("all checks passed")
|
||||
r.Header.Add("X-Anubis-Status", "PASS-FULL")
|
||||
s.next.ServeHTTP(w, r)
|
||||
s.ServeHTTPNext(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot) {
|
||||
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) {
|
||||
if returnHTTPStatusOnly {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Authorization required"))
|
||||
return
|
||||
}
|
||||
|
||||
lg := slog.With(
|
||||
"user_agent", r.UserAgent(),
|
||||
"accept_language", r.Header.Get("Accept-Language"),
|
||||
|
|
@ -470,6 +517,19 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||
timeTaken.Observe(elapsedTime)
|
||||
|
||||
response := r.FormValue("response")
|
||||
urlParsed, err := r.URL.Parse(redir)
|
||||
if err != nil {
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect URL not parseable", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host) {
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
} else if urlParsed.Host != r.URL.Host {
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Redirect domain not allowed", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
|
|
@ -184,6 +185,7 @@ func TestCookieSettings(t *testing.T) {
|
|||
}
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
resp.Write(os.Stderr)
|
||||
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue