feat: add support for a base prefix (#294)
* fix: rename variable for preventing collision in ED25519 private key handling Signed-off-by: Jason Cameron <git@jasoncameron.dev> * fix: remove unused import and debug print in xess.go Signed-off-by: Jason Cameron <git@jasoncameron.dev> * feat: introduce base path configuration for Anubis endpoints Closes: #231 Signed-off-by: Jason Cameron <git@jasoncameron.dev> * hack(internal/test): skip these tests for now Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(yeet): unbreak package builds Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Jason Cameron <git@jasoncameron.dev> Signed-off-by: Xe Iaso <me@xeiaso.net> Co-authored-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
parent
6858f66a62
commit
24f8ba729b
12 changed files with 490 additions and 110 deletions
|
|
@ -80,6 +80,7 @@ type Options struct {
|
|||
Target string
|
||||
|
||||
WebmasterEmail string
|
||||
BasePrefix string
|
||||
}
|
||||
|
||||
func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
|
||||
|
|
@ -121,6 +122,8 @@ func New(opts Options) (*Server, error) {
|
|||
opts.PrivateKey = priv
|
||||
}
|
||||
|
||||
anubis.BasePrefix = opts.BasePrefix
|
||||
|
||||
result := &Server{
|
||||
next: opts.Next,
|
||||
priv: opts.PrivateKey,
|
||||
|
|
@ -134,26 +137,42 @@ func New(opts Options) (*Server, error) {
|
|||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
|
||||
mux.Handle(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(anubis.StaticPath, http.FileServerFS(web.Static)))))
|
||||
// Helper to add global prefix
|
||||
registerWithPrefix := func(pattern string, handler http.Handler, method string) {
|
||||
if method != "" {
|
||||
method = method + " " // methods must end with a space to register with them
|
||||
}
|
||||
|
||||
if opts.ServeRobotsTXT {
|
||||
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
})
|
||||
// Ensure there's no double slash when concatenating BasePrefix and pattern
|
||||
basePrefix := strings.TrimSuffix(anubis.BasePrefix, "/")
|
||||
prefix := method + basePrefix
|
||||
|
||||
mux.HandleFunc("/.well-known/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
})
|
||||
// If pattern doesn't start with a slash, add one
|
||||
if !strings.HasPrefix(pattern, "/") {
|
||||
pattern = "/" + pattern
|
||||
}
|
||||
|
||||
mux.Handle(prefix+pattern, handler)
|
||||
}
|
||||
|
||||
// mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding)
|
||||
// Ensure there's no double slash when concatenating BasePrefix and StaticPath
|
||||
stripPrefix := strings.TrimSuffix(anubis.BasePrefix, "/") + anubis.StaticPath
|
||||
registerWithPrefix(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(http.StripPrefix(stripPrefix, http.FileServerFS(web.Static)))), "")
|
||||
|
||||
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)
|
||||
if opts.ServeRobotsTXT {
|
||||
registerWithPrefix("/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
}), "GET")
|
||||
registerWithPrefix("/.well-known/robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
}), "GET")
|
||||
}
|
||||
|
||||
mux.HandleFunc("/", result.maybeReverseProxyOrPage)
|
||||
registerWithPrefix(anubis.APIPrefix+"make-challenge", http.HandlerFunc(result.MakeChallenge), "POST")
|
||||
registerWithPrefix(anubis.APIPrefix+"pass-challenge", http.HandlerFunc(result.PassChallenge), "GET")
|
||||
registerWithPrefix(anubis.APIPrefix+"check", http.HandlerFunc(result.maybeReverseProxyHttpStatusOnly), "")
|
||||
registerWithPrefix(anubis.APIPrefix+"test-error", http.HandlerFunc(result.TestError), "GET")
|
||||
registerWithPrefix("/", http.HandlerFunc(result.maybeReverseProxyOrPage), "")
|
||||
|
||||
result.mux = mux
|
||||
|
||||
|
|
@ -561,6 +580,11 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Adjust cookie path if base prefix is not empty
|
||||
cookiePath := "/"
|
||||
if anubis.BasePrefix != "" {
|
||||
cookiePath = strings.TrimSuffix(anubis.BasePrefix, "/") + "/"
|
||||
}
|
||||
// generate JWT cookie
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
|
||||
"challenge": challenge,
|
||||
|
|
@ -585,7 +609,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
|||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: s.opts.CookieDomain,
|
||||
Partitioned: s.opts.CookiePartitioned,
|
||||
Path: "/",
|
||||
Path: cookiePath,
|
||||
})
|
||||
|
||||
challengesValidated.Inc()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
|
|
@ -254,3 +255,141 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasePrefix(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "OK")
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
basePrefix string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no prefix",
|
||||
basePrefix: "",
|
||||
path: "/.within.website/x/cmd/anubis/api/make-challenge",
|
||||
expected: "/.within.website/x/cmd/anubis/api/make-challenge",
|
||||
},
|
||||
{
|
||||
name: "with prefix",
|
||||
basePrefix: "/myapp",
|
||||
path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
|
||||
expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
|
||||
},
|
||||
{
|
||||
name: "with prefix and trailing slash",
|
||||
basePrefix: "/myapp/",
|
||||
path: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
|
||||
expected: "/myapp/.within.website/x/cmd/anubis/api/make-challenge",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Reset the global BasePrefix before each test
|
||||
anubis.BasePrefix = ""
|
||||
|
||||
pol := loadPolicies(t, "")
|
||||
pol.DefaultDifficulty = 4
|
||||
|
||||
srv := spawnAnubis(t, Options{
|
||||
Next: h,
|
||||
Policy: pol,
|
||||
BasePrefix: tc.basePrefix,
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
||||
defer ts.Close()
|
||||
|
||||
// Test API endpoint with prefix
|
||||
resp, err := ts.Client().Post(ts.URL+tc.path, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't request challenge: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status code %d, got: %d", http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
var chall challenge
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
|
||||
t.Fatalf("can't read challenge response body: %v", err)
|
||||
}
|
||||
|
||||
if chall.Challenge == "" {
|
||||
t.Errorf("expected non-empty challenge")
|
||||
}
|
||||
|
||||
// Test cookie path when passing challenge
|
||||
// Find a nonce that produces a hash with the required number of leading zeros
|
||||
nonce := 0
|
||||
var calculated string
|
||||
for {
|
||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
|
||||
calculated = internal.SHA256sum(calcString)
|
||||
if strings.HasPrefix(calculated, strings.Repeat("0", pol.DefaultDifficulty)) {
|
||||
break
|
||||
}
|
||||
nonce++
|
||||
}
|
||||
elapsedTime := 420
|
||||
redir := "/"
|
||||
|
||||
cli := ts.Client()
|
||||
cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
// Construct the correct path for pass-challenge
|
||||
passChallengePath := tc.path
|
||||
passChallengePath = passChallengePath[:strings.LastIndex(passChallengePath, "/")+1] + "pass-challenge"
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+passChallengePath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't make request: %v", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("response", calculated)
|
||||
q.Set("nonce", fmt.Sprint(nonce))
|
||||
q.Set("redir", redir)
|
||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err = cli.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("can't do challenge passing: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check cookie path
|
||||
var ckie *http.Cookie
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == anubis.CookieName {
|
||||
ckie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if ckie == nil {
|
||||
t.Errorf("Cookie %q not found", anubis.CookieName)
|
||||
return
|
||||
}
|
||||
|
||||
expectedPath := "/"
|
||||
if tc.basePrefix != "" {
|
||||
expectedPath = strings.TrimSuffix(tc.basePrefix, "/") + "/"
|
||||
}
|
||||
|
||||
if ckie.Path != expectedPath {
|
||||
t.Errorf("cookie path is wrong, wanted %s, got: %s", expectedPath, ckie.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue