diff --git a/CHALLENGES.md b/CHALLENGES.md index ba7985b..abbd33f 100644 --- a/CHALLENGES.md +++ b/CHALLENGES.md @@ -13,14 +13,15 @@ For example, this allows verifying the user cookies against the backend to have Example on Forgejo, checks that current user is authenticated: ```yaml http-cookie-check: - mode: http - url: http://forgejo:3000/user/stopwatches - # url: http://forgejo:3000/repo/search - # url: http://forgejo:3000/notifications/new + runtime: http parameters: + http-url: http://forgejo:3000/user/stopwatches + # http-url: http://forgejo:3000/repo/search + # http-url: http://forgejo:3000/notifications/new http-method: GET http-cookie: i_like_gitea http-code: 200 + verify-probability: 0.1 ``` ### preload-link @@ -35,16 +36,9 @@ Example: ```yaml self-preload-link: condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"' - mode: "preload-link" - runtime: - # verifies that result = key - mode: "key" - probability: 0.1 + runtime: "preload-link" parameters: preload-early-hint-deadline: 3s - key-code: 200 - key-mime: text/css - key-content: "" ``` ## Non-JavaScript @@ -76,20 +70,6 @@ Requires HTTP and HTML response parsing and logic, displays challenge site. Servers a challenge page with a linked resource that is loaded by the browser, which solves the challenge. Page refreshes a few seconds later via [Refresh](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Refresh). -Example: -```yaml - self-resource-load: - mode: "resource-load" - runtime: - # verifies that result = key - mode: "key" - probability: 0.1 - parameters: - key-code: 200 - key-mime: text/css - key-content: "" -``` - ## Custom JavaScript ### js-pow-sha256 @@ -101,18 +81,18 @@ Has the user solve a Proof of Work using SHA256 hashes, with configurable diffic Example: ```yaml js-pow-sha256: - # Asset must be under challenges/{name}/static/{asset} - # Other files here will be available under that path - mode: js - asset: load.mjs + runtime: js parameters: - # difficulty is number of bits that must be set to 0 from start - # Anubis challenge difficulty 5 becomes 5 * 8 = 20 - difficulty: 20 - runtime: - mode: wasm - # Verify must be under challenges/{name}/runtime/{asset} - asset: runtime.wasm - probability: 0.02 + # specifies the folder path that assets are under + # can be either embedded or external path + # defaults to name of challenge + path: "js-pow-sha256" + # needs to be under static folder + js-loader: load.mjs + # needs to be under runtime folder + wasm-runtime: runtime.wasm + wasm-runtime-settings: + difficulty: 20 + verify-probability: 0.02 ``` diff --git a/README.md b/README.md index eaeb121..b5ffd04 100644 --- a/README.md +++ b/README.md @@ -42,26 +42,19 @@ Rules and conditions are served with this environment: ``` remoteAddress (net.IP) - Connecting client remote address from headers or properties + remoteAddress.network(networkName string) bool - Check whether a given IP is listed on the underlying defined network + remoteAddress.network(networkCIDR string) bool - Check whether a given IP is listed on the CIDR host (string) - HTTP Host method (string) - HTTP Method/Verb userAgent (string) - HTTP User-Agent header path (string) - HTTP request Path query (map[string]string) - HTTP request Query arguments headers (map[string]string) - HTTP request headers - +fp (map[string]string) - Available fingerprints + Only available when TLS is enabled - fpJA3N (string) JA3N TLS Fingerprint - fpJA4 (string) JA4 TLS Fingerprint -``` - -Additionally, these functions are available: -``` -Check whether a given IP is listed on the underlying defined network or CIDR - inNetwork(networkName string, address net.IP) bool - inNetwork(networkCIDR string, address net.IP) bool - -Check whether a given IP is listed on the provided DNSBL - inDNSBL(address net.IP) bool + fp.ja3n (string) JA3N TLS Fingerprint + fp.ja4 (string) JA4 TLS Fingerprint ``` ### Template support @@ -77,14 +70,23 @@ External templates for your site can be loaded specifying a full path to the `.g ### Extended rule actions -In addition to the common PASS / CHALLENGE / DENY rules, we offer CHECK and POISON. +In addition to the common PASS / CHALLENGE / DENY rules, go-away offers more actions that can be extended via code. + +| Action | Behavior | Terminating | +|:---------:|:------------------------------------------------------------------------|:-----------:| +| PASS | Passes the request to the backend immediately | Yes | +| DENY | Denies the request with a descriptive page | Yes | +| BLOCK | Denies the request with a response code | Yes | +| DROP | Drops the connection without sending a reply | Yes | +| CHALLENGE | Issues a challenge that when passed, acts like PASS | Yes | +| CHECK | Issues a challenge that when passed, continues executing rules | No | +| PROXY | Proxies request to a different backend, with optional path replacements | Yes | + CHECK allows the client to be challenged but continue matching rules after these, for example, chaining a list of challenges that must be passed. For example, you could use this to implement browser in checks without explicitly allowing all requests, and later deferring to a secondary check/challenge. -POISON sends defined responses to bad clients that will annoy them. -This must be configured by the operator, some networks have been seen to only stop when served back this output. -Currently, an HTML payload exists that uncompressed to about one GiB of nonsense DOM. You could use this to send garbage for would-be training data. +PROXY allows the operator to send matching requests to a different backend, for example, a poison generator or a scraping maze. ### Multiple challenge matching @@ -94,7 +96,8 @@ For example: ```yaml - name: standard-browser action: challenge - challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256] + settings: + challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256] conditions: - '($is-generic-browser)' ``` @@ -394,6 +397,7 @@ services: # specify a DNSBL for usage in conditions. Defaults to DroneBL # GOAWAY_DNSBL: "dnsbl.dronebl.org" + # Backend to match. Can be subdomain or full wildcards, "*.example.com" or "*" GOAWAY_BACKEND: "git.example.com=http://forgejo:3000" # additional backends can be specified via more command arguments diff --git a/build-poison.sh b/build-poison.sh deleted file mode 100755 index 7de3ee4..0000000 --- a/build-poison.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -set -e -set -o pipefail - -cd "$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" - - -go run ./generate-poison -path ./poison/ \ No newline at end of file diff --git a/cmd/generate-poison/main.go b/cmd/generate-poison/main.go deleted file mode 100644 index f7c4bb8..0000000 --- a/cmd/generate-poison/main.go +++ /dev/null @@ -1,180 +0,0 @@ -package main - -import ( - "bytes" - "compress/gzip" - "flag" - "fmt" - "github.com/andybalholm/brotli" - "github.com/klauspost/compress/zstd" - "io" - "math/rand/v2" - "os" - "path" - "slices" - "strings" - "sync" -) - -type poisonCharacterGenerator struct { - Header []byte - AllowedBytes []byte - Repeat int - counter int -} - -func (r *poisonCharacterGenerator) Read(p []byte) (n int, err error) { - if len(r.Header) > 0 { - copy(p, r.Header) - nn := min(len(r.Header), len(p)) - r.Header = r.Header[nn:] - p = p[nn:] - } - - stride := min(len(p), r.Repeat) - for i := 0; i < len(p); i += stride { - copy(p[i:], bytes.Repeat([]byte{r.AllowedBytes[r.counter]}, stride)) - r.counter = (r.counter + 1) % len(r.AllowedBytes) - } - return len(p), nil -} - -type poisonValuesGenerator struct { - Header []byte - AllowedValues [][]byte - counter int -} - -func (r *poisonValuesGenerator) Read(p []byte) (n int, err error) { - var i int - - if len(r.Header) > 0 { - copy(p, r.Header) - nn := min(len(r.Header), len(p)) - r.Header = r.Header[nn:] - i += nn - - for i < len(p) { - copy(p[i:], r.AllowedValues[r.counter]) - i += len(r.AllowedValues[r.counter]) - r.counter = (r.counter + 1) % len(r.AllowedValues) - if r.counter == 0 { - break - } - } - } - - for i < len(p) { - buf := slices.Repeat(r.AllowedValues[r.counter], len(r.AllowedValues)-r.counter) - copy(p[i:], buf) - i += len(buf) - r.counter = (r.counter + 1) % len(r.AllowedValues) - } - return len(p), nil -} - -func main() { - - outputPath := flag.String("path", "./", "path to poison files") - - flag.Parse() - - const Gigabyte = 1024 * 1024 * 1024 - - compressPoison(*outputPath, "text/html", &poisonValuesGenerator{ - Header: []byte(fmt.Sprintf("%d", rand.Uint64())), - AllowedValues: [][]byte{ - []byte("


\n"), - []byte("

\n"), - []byte("

\n"), - []byte("

"), - []byte("
\n"), - []byte("

"), - []byte("

Are you a bot?

\n"), - []byte(""), - }, - }, Gigabyte) -} - -var poisonEncodings = []string{"br", "zstd", "gzip"} - -func compressPoison(outputPath, mime string, r io.Reader, maxSize int64) { - r = io.LimitReader(r, maxSize) - - var closers []func() - var encoders []io.Writer - var writers []io.Writer - var readers []io.Reader - - for _, encoding := range poisonEncodings { - f, err := os.Create(path.Join(outputPath, strings.ReplaceAll(mime, "/", "_")+"."+encoding+".poison")) - if err != nil { - panic(err) - } - switch encoding { - case "zstd": - w, err := zstd.NewWriter(f, zstd.WithEncoderLevel(zstd.SpeedBestCompression), zstd.WithEncoderCRC(false), zstd.WithWindowSize(zstd.MaxWindowSize)) - if err != nil { - panic(err) - } - encoders = append(encoders, w) - closers = append(closers, func() { - w.Close() - f.Close() - }) - case "br": - w := brotli.NewWriterLevel(f, brotli.BestCompression) - encoders = append(encoders, w) - closers = append(closers, func() { - w.Close() - f.Close() - }) - case "gzip": - w, err := gzip.NewWriterLevel(f, gzip.BestCompression) - if err != nil { - panic(err) - } - encoders = append(encoders, w) - closers = append(closers, func() { - w.Close() - f.Close() - }) - } - r, w := io.Pipe() - readers = append(readers, r) - writers = append(writers, w) - } - - var wg sync.WaitGroup - - for i := range poisonEncodings { - wg.Add(1) - go func() { - defer wg.Done() - - _, err := io.Copy(encoders[i], readers[i]) - if err != nil { - panic(err) - } - closers[i]() - - // discard remaining data - _, _ = io.Copy(io.Discard, readers[i]) - }() - } - - _, err := io.Copy(io.MultiWriter(writers...), r) - if err != nil { - panic(err) - } - - for _, w := range writers { - if pw, ok := w.(io.Closer); ok { - pw.Close() - } else { - panic("writer is not a Closer") - } - } - - wg.Wait() -} diff --git a/cmd/go-away/main.go b/cmd/go-away/main.go index b927001..051fbc4 100644 --- a/cmd/go-away/main.go +++ b/cmd/go-away/main.go @@ -12,10 +12,10 @@ import ( "git.gammaspectra.live/git/go-away/lib" "git.gammaspectra.live/git/go-away/lib/policy" "git.gammaspectra.live/git/go-away/utils" + "github.com/goccy/go-yaml" "github.com/pires/go-proxyproto" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" - "gopkg.in/yaml.v3" "log" "log/slog" "maps" @@ -134,8 +134,6 @@ func main() { clientIpHeader := flag.String("client-ip-header", "", "Client HTTP header to fetch their IP address from (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)") backendIpHeader := flag.String("backend-ip-header", "", "Backend HTTP header to set the client IP address from, if empty defaults to leaving Client header alone (X-Real-Ip, X-Client-Ip, X-Forwarded-For, Cf-Connecting-Ip, etc.)") - dnsbl := flag.String("dnsbl", "dnsbl.dronebl.org", "blocklist for DNSBL (default DroneBL)") - cachePath := flag.String("cache", path.Join(os.TempDir(), "go_away_cache"), "path to temporary cache directory") policyFile := flag.String("policy", "", "path to policy YAML file") @@ -318,7 +316,7 @@ func main() { }() } - settings := lib.StateSettings{ + settings := policy.Settings{ Backends: createdBackends, Debug: *debugMode, PackageName: *packageName, @@ -327,10 +325,7 @@ func main() { PrivateKeySeed: seed, ClientIpHeader: *clientIpHeader, BackendIpHeader: *backendIpHeader, - } - - if *dnsbl != "" { - settings.DNSBL = utils.NewDNSBL(*dnsbl, net.DefaultResolver) + ChallengeResponseCode: http.StatusTeapot, } state, err := lib.NewState(p, settings) diff --git a/embed/challenge/js-pow-sha256/runtime/runtime.wasm b/embed/challenge/js-pow-sha256/runtime/runtime.wasm index cb50518..e18b75e 100644 Binary files a/embed/challenge/js-pow-sha256/runtime/runtime.wasm and b/embed/challenge/js-pow-sha256/runtime/runtime.wasm differ diff --git a/embed/embed.go b/embed/embed.go index 4bf457c..934cc1e 100644 --- a/embed/embed.go +++ b/embed/embed.go @@ -1,15 +1,59 @@ package embed -import "embed" +import ( + "embed" + "errors" + "io/fs" + "os" +) //go:embed assets -var AssetsFs embed.FS +var assetsFs embed.FS //go:embed challenge -var ChallengeFs embed.FS +var challengeFs embed.FS //go:embed templates -var TemplatesFs embed.FS +var templatesFs embed.FS -//go:embed poison/*.poison -var PoisonFs embed.FS +type FSInterface interface { + fs.FS + fs.ReadDirFS + fs.ReadFileFS +} + +func trimPrefix(embedFS embed.FS, prefix string) FSInterface { + subFS, err := fs.Sub(embedFS, prefix) + if err != nil { + panic(err) + } + if properFS, ok := subFS.(FSInterface); ok { + return properFS + } else { + panic("unsupported") + } +} + +var ChallengeFs = trimPrefix(challengeFs, "challenge") + +var TemplatesFs = trimPrefix(templatesFs, "templates") +var AssetsFs = trimPrefix(assetsFs, "assets") + +func GetFallbackFS(embedFS FSInterface, prefix string) (FSInterface, error) { + var outFs fs.FS + if stat, err := os.Stat(prefix); err == nil && stat.IsDir() { + outFs = embedFS + } else if _, err := embedFS.ReadDir(prefix); err == nil { + outFs, err = fs.Sub(embedFS, prefix) + if err != nil { + return nil, err + } + } else { + return nil, err + } + if properFS, ok := outFs.(FSInterface); ok { + return properFS, nil + } else { + return nil, errors.New("unsupported FS") + } +} diff --git a/embed/poison/text_html.br.poison b/embed/poison/text_html.br.poison deleted file mode 100644 index c2550ac..0000000 Binary files a/embed/poison/text_html.br.poison and /dev/null differ diff --git a/embed/poison/text_html.gzip.poison b/embed/poison/text_html.gzip.poison deleted file mode 100644 index 14e457e..0000000 Binary files a/embed/poison/text_html.gzip.poison and /dev/null differ diff --git a/embed/poison/text_html.zstd.poison b/embed/poison/text_html.zstd.poison deleted file mode 100644 index c550f32..0000000 Binary files a/embed/poison/text_html.zstd.poison and /dev/null differ diff --git a/embed/templates/challenge-anubis.gohtml b/embed/templates/challenge-anubis.gohtml index 63e6ec2..cb82718 100644 --- a/embed/templates/challenge-anubis.gohtml +++ b/embed/templates/challenge-anubis.gohtml @@ -11,7 +11,7 @@ {{end}} {{ end }} - {{ range .Tags }} + {{ range .HeaderTags }} {{ . }} {{ end }}