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")
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))

View File

@@ -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
}
})

View File

@@ -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
}

View File

@@ -150,10 +150,15 @@
style="width:100%;max-width:256px;"
src="{{ .Path }}/assets/static/logo.png?cacheBust={{ .Random }}"
/>
<p id="status">Loading...</p>
{{if .Challenge }}
<p id="status">Loading challenge <em>{{ .Challenge }}</em>...</p>
<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}}
{{if not .HideSpinner }}
<div id="spinner" class="lds-roller">
<div></div>
<div></div>
@@ -164,14 +169,15 @@
<div></div>
<div></div>
</div>
{{end}}
<details>
<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>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>
<noscript>
<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.
</p>
</noscript>

View File

@@ -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 {