diff --git a/cmd/away.go b/cmd/away.go index 2415efe..f354147 100644 --- a/cmd/away.go +++ b/cmd/away.go @@ -91,6 +91,7 @@ func main() { target := flag.String("target", "http://localhost:80", "target to reverse proxy to") policyFile := flag.String("policy", "", "path to policy YAML file") + challengeTemplate := flag.String("challenge-template", "anubis", "name of the challenge template to use") flag.Parse() @@ -129,7 +130,11 @@ func main() { log.Fatal(fmt.Errorf("failed to create reverse proxy for %s: %w", *target, err)) } - state, err := lib.NewState(policy, "git.gammaspectra.live/git/go-away/cmd", backend) + state, err := lib.NewState(policy, lib.StateSettings{ + Backend: backend, + PackagePath: "git.gammaspectra.live/git/go-away/cmd", + ChallengeTemplate: *challengeTemplate, + }) if err != nil { log.Fatal(fmt.Errorf("failed to create state: %w", err)) diff --git a/lib/http.go b/lib/http.go index 1ff0af0..df3d3d4 100644 --- a/lib/http.go +++ b/lib/http.go @@ -1,6 +1,7 @@ package lib import ( + "bytes" "codeberg.org/meta/gzipped/v2" "crypto/rand" "encoding/base64" @@ -10,6 +11,7 @@ import ( "git.gammaspectra.live/git/go-away/lib/policy" "github.com/google/cel-go/common/types" "html/template" + "maps" "net/http" "path/filepath" "strings" @@ -52,6 +54,54 @@ func init() { } } +func (state *State) challengePage(w http.ResponseWriter, status int, challenge string, params map[string]any) error { + input := make(map[string]any) + input["Random"] = cacheBust + input["Challenge"] = challenge + input["Path"] = state.UrlPath + + maps.Copy(input, params) + + if _, ok := input["Title"]; !ok { + input["Title"] = "Checking you are not a bot" + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + buf := bytes.NewBuffer(make([]byte, 0, 8192)) + + err := templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"].Execute(buf, input) + if err != nil { + _ = state.errorPage(w, http.StatusInternalServerError, err) + } else { + w.WriteHeader(status) + _, _ = w.Write(buf.Bytes()) + } + return nil +} + +func (state *State) errorPage(w http.ResponseWriter, status int, err error) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + buf := bytes.NewBuffer(make([]byte, 0, 8192)) + + err2 := templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"].Execute(buf, map[string]any{ + "Random": cacheBust, + "Error": err.Error(), + "Path": state.UrlPath, + "Title": "Oh no! " + http.StatusText(status), + "HideSpinner": true, + "Challenge": "", + }) + if err2 != nil { + panic(err2) + } else { + w.WriteHeader(status) + _, _ = w.Write(buf.Bytes()) + } + return nil +} + func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) { //TODO better matcher! combo ast? @@ -132,11 +182,12 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) { } case policy.RuleActionDENY: //TODO: config error code - http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + _ = state.errorPage(w, http.StatusForbidden, fmt.Errorf("access denied: denied by administrative rule %s", rule.Hash)) return case policy.RuleActionBLOCK: //TODO: config error code - http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + //TODO: configure block + _ = state.errorPage(w, http.StatusForbidden, fmt.Errorf("access denied: blocked by administrative rule %s", rule.Hash)) return } } @@ -179,7 +230,7 @@ func (state *State) setupRoutes() error { return err } else if !ok { ClearCookie(CookiePrefix+challengeName, w) - http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + _ = state.errorPage(w, http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName)) return nil } @@ -195,7 +246,7 @@ func (state *State) setupRoutes() error { }() if err != nil { ClearCookie(CookiePrefix+challengeName, w) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + _ = state.errorPage(w, http.StatusInternalServerError, err) return } }) diff --git a/lib/state.go b/lib/state.go index 7245a05..f8b9e9e 100644 --- a/lib/state.go +++ b/lib/state.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" @@ -34,11 +35,11 @@ import ( ) type State struct { - Client *http.Client - PackagePath string - UrlPath string - Mux *http.ServeMux - Backend http.Handler + Client *http.Client + Settings StateSettings + UrlPath string + Mux *http.ServeMux + Backend http.Handler Networks map[string]cidranger.Ranger @@ -57,10 +58,10 @@ type State struct { type RuleState struct { Name string + Hash string Program cel.Program Action policy.RuleAction - Continue bool Challenges []string } @@ -91,16 +92,36 @@ type ChallengeState struct { Verify func(key []byte, result string) (bool, error) } -func NewState(p policy.Policy, packagePath string, backend http.Handler) (state *State, err error) { +type StateSettings struct { + Backend http.Handler + PackagePath string + ChallengeTemplate string +} + +func NewState(p policy.Policy, settings StateSettings) (state *State, err error) { state = new(State) + state.Settings = settings state.Client = &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } - state.PackagePath = packagePath - state.UrlPath = "/.well-known/." + state.PackagePath - state.Backend = backend + state.UrlPath = "/.well-known/." + state.Settings.PackagePath + state.Backend = settings.Backend + + state.PublicKey, state.PrivateKey, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + privateKeyFingerprint := sha256.Sum256(state.PrivateKey) + + if state.Settings.ChallengeTemplate == "" { + state.Settings.ChallengeTemplate = "anubis" + } + + if templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"] == nil { + return nil, fmt.Errorf("no template defined for %s", settings.ChallengeTemplate) + } state.Networks = make(map[string]cidranger.Ranger) for k, network := range p.Networks { @@ -236,19 +257,12 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state redirectUri.RawQuery = values.Encode() - // self redirect! - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusTeapot) - - _ = templates["challenge.gohtml"].Execute(w, map[string]any{ - "Title": "Bot", - "Path": state.UrlPath, - "Random": cacheBust, - "Challenge": "", + _ = state.challengePage(w, http.StatusTeapot, "", map[string]any{ "Meta": map[string]string{ "refresh": "0; url=" + redirectUri.String(), }, }) + return ChallengeResultStop } case "header-refresh": @@ -264,41 +278,25 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state // self redirect! w.Header().Set("Refresh", "0; url="+redirectUri.String()) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusTeapot) - _ = templates["challenge.gohtml"].Execute(w, map[string]any{ - "Title": "Bot", - "Path": state.UrlPath, - "Random": cacheBust, - "Challenge": "", - }) + _ = state.challengePage(w, http.StatusTeapot, "", nil) + return ChallengeResultStop } case "js": c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusTeapot) + _ = state.challengePage(w, http.StatusTeapot, challengeName, nil) - err := templates["challenge.gohtml"].Execute(w, map[string]any{ - "Title": "Bot", - "Path": state.UrlPath, - "Random": cacheBust, - "Challenge": challengeName, - }) - if err != nil { - //TODO: log - } return ChallengeResultStop } c.ChallengeScriptPath = c.Path + "/challenge.mjs" c.ChallengeScript = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + params, _ := json.Marshal(p.Parameters) + + //TODO: move this to http.go as a template w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.WriteHeader(http.StatusOK) - params, _ := json.Marshal(p.Parameters) - context.Background() - err := templates["challenge.mjs"].Execute(w, map[string]any{ "Path": c.Path, "Parameters": string(params), @@ -377,7 +375,7 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state return nil }) if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + _ = state.errorPage(w, http.StatusInternalServerError, err) return } }) @@ -484,8 +482,14 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state conditionReplacer := strings.NewReplacer(replacements...) for _, rule := range p.Rules { + hasher := sha256.New() + hasher.Write([]byte(rule.Name)) + hasher.Write(privateKeyFingerprint[:]) + sum := hasher.Sum(nil) + r := RuleState{ Name: rule.Name, + Hash: hex.EncodeToString(sum[:8]), Action: policy.RuleAction(strings.ToUpper(rule.Action)), Challenges: rule.Challenges, } @@ -511,16 +515,13 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state } r.Program = program + slog.Info("loaded rule", "rule", r.Name, "hash", r.Hash, "action", rule.Action) + state.Rules = append(state.Rules, r) } state.Mux = http.NewServeMux() - state.PublicKey, state.PrivateKey, err = ed25519.GenerateKey(rand.Reader) - if err != nil { - return nil, err - } - if err = state.setupRoutes(); err != nil { return nil, err } diff --git a/templates/challenge.gohtml b/templates/challenge-anubis.gohtml similarity index 90% rename from templates/challenge.gohtml rename to templates/challenge-anubis.gohtml index 24efdba..e290c05 100644 --- a/templates/challenge.gohtml +++ b/templates/challenge-anubis.gohtml @@ -150,10 +150,15 @@ style="width:100%;max-width:256px;" src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}" /> -

Loading...

{{if .Challenge }} +

Loading challenge {{ .Challenge }}...

+ {{else if .Error}} +

Error: {{ .Error }}

+ {{else}} +

Loading...

{{end}} + {{if not .HideSpinner }}
@@ -164,14 +169,15 @@
+ {{end}}
Why am I seeing this?

You are seeing this because the administrator of this website has set up go-away to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.

-

Please note that this challenge requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.

+

Please note that some challenges requires the use of modern JavaScript features and some plugins may will disable. Please disable such plugins for this domain (for example, JShelter).

diff --git a/templates/challenge.mjs b/templates/challenge.mjs index 3b19755..529aada 100644 --- a/templates/challenge.mjs +++ b/templates/challenge.mjs @@ -16,7 +16,7 @@ const u = (url = "", params = {}) => { const title = document.getElementById('title'); const spinner = document.getElementById('spinner'); - status.innerText = 'Starting...'; + status.innerText = 'Starting challenge {{ .Challenge }}...'; try { const info = await setup({ @@ -44,7 +44,7 @@ const u = (url = "", params = {}) => { const t1 = Date.now(); console.log({ result, info }); - title.innerHTML = "Success!"; + title.innerHTML = "Challenge success!"; if (info != "") { status.innerHTML = `Done! Took ${t1 - t0}ms, ${info}`; } else {