Proper challenge/error pages
This commit is contained in:
@@ -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))
|
||||
|
59
lib/http.go
59
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
|
||||
}
|
||||
})
|
||||
|
95
lib/state.go
95
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
|
||||
}
|
||||
|
@@ -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>
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user