diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index d25e048..4a09558 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -81,6 +81,7 @@ goodbot googlebot govulncheck GPG +grw Hashcash hashrate headermap diff --git a/anubis.go b/anubis.go index c487bd7..8e5c1c1 100644 --- a/anubis.go +++ b/anubis.go @@ -16,6 +16,8 @@ const CookieName = "techaro.lol-anubis-auth" // WithDomainCookieName is the name that is prepended to the per-domain cookie used when COOKIE_DOMAIN is set. const WithDomainCookieName = "techaro.lol-anubis-auth-for-" +const TestCookieName = "techaro.lol-anubis-cookie-test-if-you-block-this-anubis-wont-work" + // CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires. const CookieDefaultExpirationTime = 7 * 24 * time.Hour diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 4f923d6..c719e18 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Ensure that clients that are shown a challenge support storing cookies +- Encode challenge pages with gzip level 1 - Add `check-spelling` for spell checking - Add `--target-insecure-skip-verify` flag/envvar to allow Anubis to hit a self-signed HTTPS backend - Minor adjustments to FreeBSD rc.d script to allow for more flexible configuration. diff --git a/internal/gzip.go b/internal/gzip.go new file mode 100644 index 0000000..c83a0ed --- /dev/null +++ b/internal/gzip.go @@ -0,0 +1,35 @@ +package internal + +import ( + "compress/gzip" + "net/http" + "strings" +) + +func GzipMiddleware(level int, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + + w.Header().Set("Content-Encoding", "gzip") + gz, err := gzip.NewWriterLevel(w, level) + if err != nil { + panic(err) + } + defer gz.Close() + + grw := gzipResponseWriter{ResponseWriter: w, sink: gz} + next.ServeHTTP(grw, r) + }) +} + +type gzipResponseWriter struct { + http.ResponseWriter + sink *gzip.Writer +} + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.sink.Write(b) +} diff --git a/lib/anubis.go b/lib/anubis.go index 4ca584e..60a007c 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -121,21 +121,21 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS ckie, err := r.Cookie(s.cookieName) if err != nil { lg.Debug("cookie not found", "path", r.URL.Path) - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) s.RenderIndex(w, r, rule, httpStatusOnly) return } if err := ckie.Valid(); err != nil { lg.Debug("cookie is invalid", "err", err) - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) 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.ClearCookie(w, s.cookieName) s.RenderIndex(w, r, rule, httpStatusOnly) return } @@ -146,7 +146,7 @@ func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request, httpS if err != nil || !token.Valid { lg.Debug("invalid token", "path", r.URL.Path, "err", err) - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) s.RenderIndex(w, r, rule, httpStatusOnly) return } @@ -162,7 +162,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch s.ServeHTTPNext(w, r) return true case config.RuleDeny: - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Info("explicit deny") if rule == nil { lg.Error("rule is nil, cannot calculate checksum") @@ -181,7 +181,7 @@ func (s *Server) checkRules(w http.ResponseWriter, r *http.Request, cr policy.Ch s.RenderBench(w, r) return true default: - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) slog.Error("CONFIG ERROR: unknown rule", "rule", cr.Rule) s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy.Rules\"") return true @@ -233,6 +233,8 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { lg = lg.With("check_result", cr) challenge := s.challengeFor(r, rule.Challenge.Difficulty) + s.SetCookie(w, anubis.TestCookieName, challenge, "") + err = encoder.Encode(struct { Rules *config.ChallengeRules `json:"rules"` Challenge string `json:"challenge"` @@ -265,14 +267,14 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { cr, rule, err := s.check(r) if err != nil { lg.Error("check failed", "err", err) - s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".\"") + s.respondWithError(w, r, "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".") return } lg = lg.With("check_result", cr) nonceStr := r.FormValue("nonce") if nonceStr == "" { - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Debug("no nonce") s.respondWithError(w, r, "missing nonce") return @@ -280,7 +282,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { elapsedTimeStr := r.FormValue("elapsedTime") if elapsedTimeStr == "" { - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Debug("no elapsedTime") s.respondWithError(w, r, "missing elapsedTime") return @@ -288,7 +290,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64) if err != nil { - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Debug("elapsedTime doesn't parse", "err", err) s.respondWithError(w, r, "invalid elapsedTime") return @@ -310,11 +312,21 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { challenge := s.challengeFor(r, rule.Challenge.Difficulty) + if _, err := r.Cookie(anubis.TestCookieName); err == http.ErrNoCookie { + s.ClearCookie(w, s.cookieName) + s.ClearCookie(w, anubis.TestCookieName) + lg.Warn("user has cookies disabled, this is not an anubis bug") + s.respondWithError(w, r, "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain") + return + } + + s.ClearCookie(w, anubis.TestCookieName) + nonce, err := strconv.Atoi(nonceStr) if err != nil { - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Debug("nonce doesn't parse", "err", err) - s.respondWithError(w, r, "invalid nonce") + s.respondWithError(w, r, "invalid response") return } @@ -322,7 +334,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { calculated := internal.SHA256sum(calcString) if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Debug("hash does not match", "got", response, "want", calculated) s.respondWithStatus(w, r, "invalid response", http.StatusForbidden) failedValidations.Inc() @@ -331,7 +343,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { // compare the leading zeroes if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) { - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty) s.respondWithStatus(w, r, "invalid response", http.StatusForbidden) failedValidations.Inc() @@ -355,20 +367,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { tokenString, err := token.SignedString(s.priv) if err != nil { lg.Error("failed to sign JWT", "err", err) - s.ClearCookie(w) + s.ClearCookie(w, s.cookieName) s.respondWithError(w, r, "failed to sign JWT") return } - http.SetCookie(w, &http.Cookie{ - Name: s.cookieName, - Value: tokenString, - Expires: time.Now().Add(s.opts.CookieExpiration), - SameSite: http.SameSiteLaxMode, - Domain: s.opts.CookieDomain, - Partitioned: s.opts.CookiePartitioned, - Path: cookiePath, - }) + s.SetCookie(w, s.cookieName, tokenString, cookiePath) challengesValidated.Inc() lg.Debug("challenge passed, redirecting to app") diff --git a/lib/anubis_test.go b/lib/anubis_test.go index 8767278..55aa09a 100644 --- a/lib/anubis_test.go +++ b/lib/anubis_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/cookiejar" "net/http/httptest" "os" "strings" @@ -43,10 +44,10 @@ type challenge struct { Challenge string `json:"challenge"` } -func makeChallenge(t *testing.T, ts *httptest.Server) challenge { +func makeChallenge(t *testing.T, ts *httptest.Server, cli *http.Client) challenge { t.Helper() - resp, err := ts.Client().Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil) + resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil) if err != nil { t.Fatalf("can't request challenge: %v", err) } @@ -60,6 +61,54 @@ func makeChallenge(t *testing.T, ts *httptest.Server) challenge { return chall } +func handleChallengeZeroDifficulty(t *testing.T, ts *httptest.Server, cli *http.Client, chall challenge) *http.Response { + t.Helper() + + nonce := 0 + elapsedTime := 420 + redir := "/" + calculated := "" + calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce) + calculated = internal.SHA256sum(calcString) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", 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 request: %v", err) + } + + return resp +} + +func httpClient(t *testing.T) *http.Client { + t.Helper() + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + + cli := &http.Client{ + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return cli +} + func TestLoadPolicies(t *testing.T) { for _, fname := range []string{"botPolicies.json", "botPolicies.yaml"} { t.Run(fname, func(t *testing.T) { @@ -85,7 +134,6 @@ func TestCVE2025_24369(t *testing.T) { Next: http.NewServeMux(), Policy: pol, - CookieDomain: ".local.cetacean.club", CookiePartitioned: true, CookieName: t.Name(), }) @@ -93,34 +141,9 @@ func TestCVE2025_24369(t *testing.T) { ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() - chall := makeChallenge(t, ts) - calcString := fmt.Sprintf("%s%d", chall.Challenge, 0) - calculated := internal.SHA256sum(calcString) - nonce := 0 - elapsedTime := 420 - redir := "/" - - cli := ts.Client() - cli.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", 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") - } + cli := httpClient(t) + chall := makeChallenge(t, ts, cli) + resp := handleChallengeZeroDifficulty(t, ts, cli, chall) if resp.StatusCode == http.StatusFound { t.Log("Regression on CVE-2025-24369") @@ -137,58 +160,18 @@ func TestCookieCustomExpiration(t *testing.T) { Next: http.NewServeMux(), Policy: pol, - CookieDomain: "local.cetacean.club", - CookieName: t.Name(), CookieExpiration: ckieExpiration, }) ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() - cli := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } + cli := httpClient(t) + chall := makeChallenge(t, ts, cli) - resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil) - if err != nil { - t.Fatalf("can't request challenge: %v", err) - } - defer resp.Body.Close() - - var chall = struct { - Challenge string `json:"challenge"` - }{} - if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil { - t.Fatalf("can't read challenge response body: %v", err) - } - - nonce := 0 - elapsedTime := 420 - redir := "/" - calculated := "" - calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce) - calculated = internal.SHA256sum(calcString) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", 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() - - requestReceiveLowerBound := time.Now() - resp, err = cli.Do(req) + requestReceiveLowerBound := time.Now().Add(-1 * time.Minute) + resp := handleChallengeZeroDifficulty(t, ts, cli, chall) requestReceiveUpperBound := time.Now() - if err != nil { - t.Fatalf("can't do challenge passing") - } if resp.StatusCode != http.StatusFound { resp.Write(os.Stderr) @@ -226,59 +209,21 @@ func TestCookieSettings(t *testing.T) { Next: http.NewServeMux(), Policy: pol, - CookieDomain: "local.cetacean.club", + CookieDomain: "127.0.0.1", CookiePartitioned: true, CookieName: t.Name(), CookieExpiration: anubis.CookieDefaultExpirationTime, }) + requestReceiveLowerBound := time.Now() ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv)) defer ts.Close() - cli := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } + cli := httpClient(t) + chall := makeChallenge(t, ts, cli) - resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil) - if err != nil { - t.Fatalf("can't request challenge: %v", err) - } - defer resp.Body.Close() - - var chall = struct { - Challenge string `json:"challenge"` - }{} - if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil { - t.Fatalf("can't read challenge response body: %v", err) - } - - nonce := 0 - elapsedTime := 420 - redir := "/" - calculated := "" - calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce) - calculated = internal.SHA256sum(calcString) - - req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", 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() - - requestReceiveLowerBound := time.Now() - resp, err = cli.Do(req) + resp := handleChallengeZeroDifficulty(t, ts, cli, chall) requestReceiveUpperBound := time.Now() - if err != nil { - t.Fatalf("can't do challenge passing") - } if resp.StatusCode != http.StatusFound { resp.Write(os.Stderr) @@ -298,8 +243,8 @@ func TestCookieSettings(t *testing.T) { return } - if ckie.Domain != "local.cetacean.club" { - t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain) + if ckie.Domain != "127.0.0.1" { + t.Errorf("cookie domain is wrong, wanted 127.0.0.1, got: %s", ckie.Domain) } expirationLowerBound := requestReceiveLowerBound.Add(anubis.CookieDefaultExpirationTime) @@ -457,6 +402,10 @@ func TestBasePrefix(t *testing.T) { t.Fatalf("can't make request: %v", err) } + for _, ckie := range resp.Cookies() { + req.AddCookie(ckie) + } + q := req.URL.Query() q.Set("response", calculated) q.Set("nonce", fmt.Sprint(nonce)) @@ -561,6 +510,25 @@ func TestCloudflareWorkersRule(t *testing.T) { t.Fatalf("can't construct libanubis.Server: %v", err) } + t.Run("with-cf-worker-header", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/", nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Add("X-Real-Ip", "127.0.0.1") + req.Header.Add("Cf-Worker", "true") + + cr, _, err := s.check(req) + if err != nil { + t.Fatal(err) + } + + if cr.Rule != config.RuleDeny { + t.Errorf("rule is wrong, wanted %s, got: %s", config.RuleDeny, cr.Rule) + } + }) + t.Run("no-cf-worker-header", func(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "/", nil) if err != nil { diff --git a/lib/http.go b/lib/http.go index 27c1dad..27f9fa0 100644 --- a/lib/http.go +++ b/lib/http.go @@ -1,19 +1,34 @@ package lib import ( + "math/rand" "net/http" "slices" + "strings" "time" + "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/web" "github.com/a-h/templ" ) -func (s *Server) ClearCookie(w http.ResponseWriter) { +func (s *Server) SetCookie(w http.ResponseWriter, name, value, path string) { http.SetCookie(w, &http.Cookie{ - Name: s.cookieName, + Name: name, + Value: value, + Expires: time.Now().Add(s.opts.CookieExpiration), + SameSite: http.SameSiteLaxMode, + Domain: s.opts.CookieDomain, + Partitioned: s.opts.CookiePartitioned, + Path: path, + }) +} + +func (s *Server) ClearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Expires: time.Now().Add(-1 * time.Hour), MaxAge: -1, @@ -38,6 +53,10 @@ func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return t.Transport.RoundTrip(req) } +func randomChance(n int) bool { + return rand.Intn(n) == 0 +} + func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *policy.Bot, returnHTTPStatusOnly bool) { if returnHTTPStatusOnly { w.WriteHeader(http.StatusUnauthorized) @@ -47,6 +66,11 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic lg := internal.GetRequestLogger(r) + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") && randomChance(64) { + lg.Error("client was given a challenge but does not in fact support gzip compression") + s.respondWithError(w, r, "Client Error: Please ensure your browser is up to date and try again later.") + } + challenge := s.challengeFor(r, rule.Challenge.Difficulty) var ogTags map[string]string = nil @@ -58,6 +82,8 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic } } + s.SetCookie(w, anubis.TestCookieName, challenge, "") + component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags) if err != nil { lg.Error("render failed, please open an issue", "err", err) // This is likely a bug in the template. Should never be triggered as CI tests for this. @@ -65,10 +91,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic return } - handler := internal.NoStoreCache(templ.Handler( + handler := internal.GzipMiddleware(1, internal.NoStoreCache(templ.Handler( component, templ.WithStatus(s.opts.Policy.StatusCodes.Challenge), - )) + ))) handler.ServeHTTP(w, r) } diff --git a/lib/http_test.go b/lib/http_test.go index 9f32d3b..0a01799 100644 --- a/lib/http_test.go +++ b/lib/http_test.go @@ -11,7 +11,7 @@ func TestClearCookie(t *testing.T) { srv := spawnAnubis(t, Options{}) rw := httptest.NewRecorder() - srv.ClearCookie(rw) + srv.ClearCookie(rw, srv.cookieName) resp := rw.Result() @@ -36,7 +36,7 @@ func TestClearCookieWithDomain(t *testing.T) { srv := spawnAnubis(t, Options{CookieDomain: "techaro.lol"}) rw := httptest.NewRecorder() - srv.ClearCookie(rw) + srv.ClearCookie(rw, srv.cookieName) resp := rw.Result() diff --git a/lib/testdata/cloudflare-workers-cel.yaml b/lib/testdata/cloudflare-workers-cel.yaml index 123b634..b2d6de5 100644 --- a/lib/testdata/cloudflare-workers-cel.yaml +++ b/lib/testdata/cloudflare-workers-cel.yaml @@ -1,4 +1,8 @@ bots: - name: cloudflare-workers expression: '"Cf-Worker" in headers' - action: DENY \ No newline at end of file + action: DENY + +status_codes: + CHALLENGE: 401 + DENY: 403 \ No newline at end of file diff --git a/lib/testdata/cloudflare-workers-header.yaml b/lib/testdata/cloudflare-workers-header.yaml index 89bc069..4fa9671 100644 --- a/lib/testdata/cloudflare-workers-header.yaml +++ b/lib/testdata/cloudflare-workers-header.yaml @@ -2,4 +2,8 @@ bots: - name: cloudflare-workers headers_regex: CF-Worker: .* - action: DENY \ No newline at end of file + action: DENY + +status_codes: + CHALLENGE: 401 + DENY: 403 \ No newline at end of file diff --git a/package.json b/package.json index 8f19909..fe4e7ad 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test:integration:docker": "npm run assets && go test -v ./internal/test --playwright-runner=docker", "assets": "go generate ./... && ./web/build.sh && ./xess/build.sh", "build": "npm run assets && go build -o ./var/anubis ./cmd/anubis", - "dev": "npm run assets && go run ./cmd/anubis --use-remote-address", + "dev": "npm run assets && go run ./cmd/anubis --use-remote-address --target http://localhost:3000", "container": "npm run assets && go run ./cmd/containerbuild", "package": "yeet", "lint": "make lint" @@ -27,4 +27,4 @@ "postcss-import-url": "^7.2.0", "postcss-url": "^10.1.3" } -} \ No newline at end of file +}