Proper challenge/error pages

This commit is contained in:
WeebDataHoarder
2025-04-01 22:12:15 +02:00
parent df5e125cf2
commit 3cd880b169
5 changed files with 120 additions and 57 deletions

View File

@@ -91,6 +91,7 @@ func main() {
target := flag.String("target", "http://localhost:80", "target to reverse proxy to") target := flag.String("target", "http://localhost:80", "target to reverse proxy to")
policyFile := flag.String("policy", "", "path to policy YAML file") policyFile := flag.String("policy", "", "path to policy YAML file")
challengeTemplate := flag.String("challenge-template", "anubis", "name of the challenge template to use")
flag.Parse() flag.Parse()
@@ -129,7 +130,11 @@ func main() {
log.Fatal(fmt.Errorf("failed to create reverse proxy for %s: %w", *target, err)) 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 { if err != nil {
log.Fatal(fmt.Errorf("failed to create state: %w", err)) log.Fatal(fmt.Errorf("failed to create state: %w", err))

View File

@@ -1,6 +1,7 @@
package lib package lib
import ( import (
"bytes"
"codeberg.org/meta/gzipped/v2" "codeberg.org/meta/gzipped/v2"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
@@ -10,6 +11,7 @@ import (
"git.gammaspectra.live/git/go-away/lib/policy" "git.gammaspectra.live/git/go-away/lib/policy"
"github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types"
"html/template" "html/template"
"maps"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "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) { func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
//TODO better matcher! combo ast? //TODO better matcher! combo ast?
@@ -132,11 +182,12 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
} }
case policy.RuleActionDENY: case policy.RuleActionDENY:
//TODO: config error code //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 return
case policy.RuleActionBLOCK: case policy.RuleActionBLOCK:
//TODO: config error code //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 return
} }
} }
@@ -179,7 +230,7 @@ func (state *State) setupRoutes() error {
return err return err
} else if !ok { } else if !ok {
ClearCookie(CookiePrefix+challengeName, w) 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 return nil
} }
@@ -195,7 +246,7 @@ func (state *State) setupRoutes() error {
}() }()
if err != nil { if err != nil {
ClearCookie(CookiePrefix+challengeName, w) ClearCookie(CookiePrefix+challengeName, w)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) _ = state.errorPage(w, http.StatusInternalServerError, err)
return return
} }
}) })

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -34,11 +35,11 @@ import (
) )
type State struct { type State struct {
Client *http.Client Client *http.Client
PackagePath string Settings StateSettings
UrlPath string UrlPath string
Mux *http.ServeMux Mux *http.ServeMux
Backend http.Handler Backend http.Handler
Networks map[string]cidranger.Ranger Networks map[string]cidranger.Ranger
@@ -57,10 +58,10 @@ type State struct {
type RuleState struct { type RuleState struct {
Name string Name string
Hash string
Program cel.Program Program cel.Program
Action policy.RuleAction Action policy.RuleAction
Continue bool
Challenges []string Challenges []string
} }
@@ -91,16 +92,36 @@ type ChallengeState struct {
Verify func(key []byte, result string) (bool, error) 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 = new(State)
state.Settings = settings
state.Client = &http.Client{ state.Client = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
} }
state.PackagePath = packagePath state.UrlPath = "/.well-known/." + state.Settings.PackagePath
state.UrlPath = "/.well-known/." + state.PackagePath state.Backend = settings.Backend
state.Backend = 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) state.Networks = make(map[string]cidranger.Ranger)
for k, network := range p.Networks { 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() redirectUri.RawQuery = values.Encode()
// self redirect! _ = state.challengePage(w, http.StatusTeapot, "", map[string]any{
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": "",
"Meta": map[string]string{ "Meta": map[string]string{
"refresh": "0; url=" + redirectUri.String(), "refresh": "0; url=" + redirectUri.String(),
}, },
}) })
return ChallengeResultStop return ChallengeResultStop
} }
case "header-refresh": case "header-refresh":
@@ -264,41 +278,25 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state
// self redirect! // self redirect!
w.Header().Set("Refresh", "0; url="+redirectUri.String()) 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{ _ = state.challengePage(w, http.StatusTeapot, "", nil)
"Title": "Bot",
"Path": state.UrlPath,
"Random": cacheBust,
"Challenge": "",
})
return ChallengeResultStop return ChallengeResultStop
} }
case "js": case "js":
c.Challenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) ChallengeResult { 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") _ = state.challengePage(w, http.StatusTeapot, challengeName, nil)
w.WriteHeader(http.StatusTeapot)
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 return ChallengeResultStop
} }
c.ChallengeScriptPath = c.Path + "/challenge.mjs" c.ChallengeScriptPath = c.Path + "/challenge.mjs"
c.ChallengeScript = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
params, _ := json.Marshal(p.Parameters)
context.Background()
err := templates["challenge.mjs"].Execute(w, map[string]any{ err := templates["challenge.mjs"].Execute(w, map[string]any{
"Path": c.Path, "Path": c.Path,
"Parameters": string(params), "Parameters": string(params),
@@ -377,7 +375,7 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state
return nil return nil
}) })
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) _ = state.errorPage(w, http.StatusInternalServerError, err)
return return
} }
}) })
@@ -484,8 +482,14 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state
conditionReplacer := strings.NewReplacer(replacements...) conditionReplacer := strings.NewReplacer(replacements...)
for _, rule := range p.Rules { for _, rule := range p.Rules {
hasher := sha256.New()
hasher.Write([]byte(rule.Name))
hasher.Write(privateKeyFingerprint[:])
sum := hasher.Sum(nil)
r := RuleState{ r := RuleState{
Name: rule.Name, Name: rule.Name,
Hash: hex.EncodeToString(sum[:8]),
Action: policy.RuleAction(strings.ToUpper(rule.Action)), Action: policy.RuleAction(strings.ToUpper(rule.Action)),
Challenges: rule.Challenges, Challenges: rule.Challenges,
} }
@@ -511,16 +515,13 @@ func NewState(p policy.Policy, packagePath string, backend http.Handler) (state
} }
r.Program = program r.Program = program
slog.Info("loaded rule", "rule", r.Name, "hash", r.Hash, "action", rule.Action)
state.Rules = append(state.Rules, r) state.Rules = append(state.Rules, r)
} }
state.Mux = http.NewServeMux() 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 { if err = state.setupRoutes(); err != nil {
return nil, err return nil, err
} }

View File

@@ -150,10 +150,15 @@
style="width:100%;max-width:256px;" style="width:100%;max-width:256px;"
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}" src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
/> />
<p id="status">Loading...</p>
{{if .Challenge }} {{if .Challenge }}
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script> <script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
{{else if .Error}}
<p id="status">Error: {{ .Error }}</p>
{{else}}
<p id="status">Loading...</p>
{{end}} {{end}}
{{if not .HideSpinner }}
<div id="spinner" class="lds-roller"> <div id="spinner" class="lds-roller">
<div></div> <div></div>
<div></div> <div></div>
@@ -164,14 +169,15 @@
<div></div> <div></div>
<div></div> <div></div>
</div> </div>
{{end}}
<details> <details>
<summary>Why am I seeing this?</summary> <summary>Why am I seeing this?</summary>
<p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p> <p>You are seeing this because the administrator of this website has set up <a href="https://git.gammaspectra.live/git/go-away">go-away</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
<p>Please note that this challenge requires the use of modern JavaScript features that plugins like <a href="https://jshelter.org/">JShelter</a> will disable. Please disable JShelter or other such plugins for this domain.</p> <p>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).</p>
</details> </details>
<noscript> <noscript>
<p> <p>
Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed Sadly, you may need to enable JavaScript to get past this challenge. This is required because AI companies have changed
the social contract around how website hosting works. the social contract around how website hosting works.
</p> </p>
</noscript> </noscript>

View File

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