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")
|
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))
|
||||||
|
59
lib/http.go
59
lib/http.go
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
95
lib/state.go
95
lib/state.go
@@ -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
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
@@ -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 {
|
||||||
|
Reference in New Issue
Block a user