Condition, rules, state and action refactor / rewrite
Add nested rules Add backend action, allow wildcard in backends Remove poison from tree, update README with action table Allow defining pass/fail actions on challenge, Remove redirect/referer parameters on backend pass Set challenge cookie tied to host Rewrite DNSBL condition into a challenge Allow passing an arbitrary path for assets to js challenges Optimize programs exhaustively on compilation Activation instead of map for CEL context, faster map access, new network override Return valid host on cookie setting in case Host is an IP address. bug: does not work with IPv6, see https://github.com/golang/go/issues/65521 Apply TLS fingerprinter on GetConfigForClient instead of GetCertificate Cleanup go-away cookies before passing to backend Code action for specifically replying with an HTTP code
This commit is contained in:
committed by
WeebDataHoarder
parent
1c7fe1bed9
commit
ead41055ca
@@ -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
|
||||
```
|
||||
|
||||
|
40
README.md
40
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
|
||||
|
@@ -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/
|
@@ -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("<!DOCTYPE html><html><head><title>%d</title></head><body>", rand.Uint64())),
|
||||
AllowedValues: [][]byte{
|
||||
[]byte("<div><div class=\"\"><h2></h2></div><br>\n"),
|
||||
[]byte("<span><span><p><span>\n"),
|
||||
[]byte("<p></span></script><h3><p><span>\n"),
|
||||
[]byte("<div><span><p></h1>"),
|
||||
[]byte("</div></div></div>\n"),
|
||||
[]byte("</p></p></p>"),
|
||||
[]byte("<h1>Are you a bot?</h1><img>\n"),
|
||||
[]byte("</span></span></span><script>{let a = (new XMLSerializer).serializeToString(document); console.log(a); let b = URL.createObjectURL(new Blob([a])); Array.from(document.getElementsByTagName(\"img\")).forEach((img) => {img.src = b;}); document.getElementsByTagName(\"body\")[0].prepend((new DOMParser()).parseFromString(a, \"text/html\"));}</script>"),
|
||||
},
|
||||
}, 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()
|
||||
}
|
@@ -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)
|
||||
|
Binary file not shown.
@@ -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")
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,7 +11,7 @@
|
||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||
{{end}}
|
||||
{{ end }}
|
||||
{{ range .Tags }}
|
||||
{{ range .HeaderTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
<style>
|
||||
@@ -155,7 +155,6 @@
|
||||
/>
|
||||
{{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}}
|
||||
@@ -202,6 +201,11 @@
|
||||
</p>
|
||||
</center>
|
||||
</footer>
|
||||
|
||||
|
||||
{{ range .EndTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@@ -17,7 +17,7 @@
|
||||
<meta name="{{ $key }}" content="{{ $value }}"/>
|
||||
{{end}}
|
||||
{{ end }}
|
||||
{{ range .Tags }}
|
||||
{{ range .HeaderTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
|
||||
{{if .Challenge }}
|
||||
<h3 id="status">Loading challenge <em>{{ .Challenge }}</em>...</h3>
|
||||
<script async type="module" src="{{ .Path }}/challenge/{{ .Challenge }}/challenge.mjs?cacheBust={{ .Random }}"></script>
|
||||
{{else if .Error}}
|
||||
<h3 id="status">Error: {{ .Error }}</h3>
|
||||
{{else}}
|
||||
@@ -110,5 +109,9 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{ range .EndTags }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -19,42 +19,42 @@ networks:
|
||||
# AS21859
|
||||
- url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/21859/aggregated.json
|
||||
jq-path: '.subnets.ipv4[], .subnets.ipv6[]'
|
||||
aws-cloud:
|
||||
- url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||
jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
||||
google-cloud:
|
||||
- url: https://www.gstatic.com/ipranges/cloud.json
|
||||
jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
oracle-cloud:
|
||||
- url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
||||
jq-path: '.regions[] | .cidrs[] | .cidr'
|
||||
azure-cloud:
|
||||
# todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
||||
- url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
||||
jq-path: '.values[] | .properties.addressPrefixes[]'
|
||||
|
||||
digitalocean:
|
||||
- url: https://www.digitalocean.com/geo/google.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
linode:
|
||||
- url: https://geoip.linode.com/
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
vultr:
|
||||
- url: "https://geofeed.constant.com/?json"
|
||||
jq-path: '.subnets[] | .ip_prefix'
|
||||
cloudflare:
|
||||
- url: https://www.cloudflare.com/ips-v4
|
||||
regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
- url: https://www.cloudflare.com/ips-v6
|
||||
regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
|
||||
icloud-private-relay:
|
||||
- url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
||||
regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
tunnelbroker-relay:
|
||||
# HE Tunnelbroker
|
||||
- url: https://tunnelbroker.net/export/google
|
||||
regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
||||
# aws-cloud:
|
||||
# - url: https://ip-ranges.amazonaws.com/ip-ranges.json
|
||||
# jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)'
|
||||
# google-cloud:
|
||||
# - url: https://www.gstatic.com/ipranges/cloud.json
|
||||
# jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)'
|
||||
# oracle-cloud:
|
||||
# - url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json
|
||||
# jq-path: '.regions[] | .cidrs[] | .cidr'
|
||||
# azure-cloud:
|
||||
# # todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON
|
||||
# - url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json
|
||||
# jq-path: '.values[] | .properties.addressPrefixes[]'
|
||||
#
|
||||
# digitalocean:
|
||||
# - url: https://www.digitalocean.com/geo/google.csv
|
||||
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
# linode:
|
||||
# - url: https://geoip.linode.com/
|
||||
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
# vultr:
|
||||
# - url: "https://geofeed.constant.com/?json"
|
||||
# jq-path: '.subnets[] | .ip_prefix'
|
||||
# cloudflare:
|
||||
# - url: https://www.cloudflare.com/ips-v4
|
||||
# regex: "(?P<prefix>[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)"
|
||||
# - url: https://www.cloudflare.com/ips-v6
|
||||
# regex: "(?P<prefix>[0-9a-f:]+::/[0-9]+)"
|
||||
#
|
||||
# icloud-private-relay:
|
||||
# - url: https://mask-api.icloud.com/egress-ip-ranges.csv
|
||||
# regex: "(?P<prefix>(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+),"
|
||||
# tunnelbroker-relay:
|
||||
# # HE Tunnelbroker
|
||||
# - url: https://tunnelbroker.net/export/google
|
||||
# regex: "(?P<prefix>([0-9a-f:]+::)/[0-9]+),"
|
||||
|
||||
|
||||
googlebot:
|
||||
@@ -95,77 +95,65 @@ networks:
|
||||
|
||||
challenges:
|
||||
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: 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
|
||||
|
||||
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
|
||||
self-cookie:
|
||||
mode: "cookie"
|
||||
|
||||
runtime: "cookie"
|
||||
|
||||
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
|
||||
# Works on HTTP/2 and above!
|
||||
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: ""
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
self-header-refresh:
|
||||
mode: "header-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "header"
|
||||
|
||||
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||
self-meta-refresh:
|
||||
mode: "meta-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "meta"
|
||||
|
||||
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
|
||||
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: ""
|
||||
|
||||
runtime: "resource-load"
|
||||
|
||||
# Verifies the existence of a cookie and confirms it against some backend request, passing the entire client cookie contents
|
||||
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
|
||||
|
||||
dnsbl:
|
||||
runtime: dnsbl
|
||||
parameters:
|
||||
dnsbl-decay: 1h
|
||||
dnsbl-timeout: 1s
|
||||
|
||||
conditions:
|
||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
||||
@@ -228,7 +216,7 @@ conditions:
|
||||
|
||||
is-suspicious-crawler:
|
||||
# TLS Fingerprint for specific agent without ALPN
|
||||
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && fpJA4.matches("^t[0-9a-z]+00_")'
|
||||
- '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && ("ja4" in fp && fp.ja4.matches("^t[0-9a-z]+00_"))'
|
||||
# Old engines
|
||||
- 'userAgent.contains("Presto/") || userAgent.contains("Trident/")'
|
||||
# Old IE browsers
|
||||
@@ -243,6 +231,7 @@ conditions:
|
||||
- 'userAgent.startsWith("Opera/")'
|
||||
#- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")'
|
||||
- 'userAgent.matches("^Mozilla/[1-4]")'
|
||||
|
||||
is-heavy-resource:
|
||||
- 'path.startsWith("/explore/")'
|
||||
- 'path.matches("^/[^/]+/[^/]+/src/commit/")'
|
||||
@@ -271,8 +260,8 @@ rules:
|
||||
|
||||
- name: undesired-networks
|
||||
conditions:
|
||||
- 'inNetwork("huawei-cloud", remoteAddress) || inNetwork("alibaba-cloud", remoteAddress) || inNetwork("zenlayer-inc", remoteAddress)'
|
||||
action: poison
|
||||
- 'remoteAddress.network("huawei-cloud") || remoteAddress.network("alibaba-cloud") || remoteAddress.network("zenlayer-inc")'
|
||||
action: drop
|
||||
|
||||
- name: undesired-crawlers
|
||||
conditions:
|
||||
@@ -296,7 +285,7 @@ rules:
|
||||
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
|
||||
# SEO / Ads and marketing
|
||||
- 'userAgent.contains("BLEXBot")'
|
||||
action: poison
|
||||
action: drop
|
||||
|
||||
- name: unknown-crawlers
|
||||
conditions:
|
||||
@@ -305,22 +294,22 @@ rules:
|
||||
action: deny
|
||||
|
||||
# check a sequence of challenges for non logged in
|
||||
- name: suspicious-crawlers/0
|
||||
- name: suspicious-crawlers
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
- name: suspicious-crawlers/1
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-preload-link]
|
||||
- name: suspicious-crawlers/2
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-header-refresh]
|
||||
- name: suspicious-crawlers/3
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-resource-load]
|
||||
action: none
|
||||
children:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [self-preload-link, self-resource-load]
|
||||
- name: 2
|
||||
action: check
|
||||
settings:
|
||||
challenges: [self-header-refresh]
|
||||
|
||||
- name: always-pow-challenge
|
||||
conditions:
|
||||
@@ -337,7 +326,8 @@ rules:
|
||||
# Match archive downloads from browsers and not tools
|
||||
- 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)'
|
||||
action: challenge
|
||||
challenges: [ js-pow-sha256 ]
|
||||
settings:
|
||||
challenges: [ js-pow-sha256 ]
|
||||
|
||||
- name: allow-git-operations
|
||||
conditions:
|
||||
@@ -398,27 +388,34 @@ rules:
|
||||
|
||||
- name: desired-crawlers
|
||||
conditions:
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && remoteAddress.network("kagibot")'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && remoteAddress.network("googlebot")'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && remoteAddress.network("bingbot")'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && remoteAddress.network("duckduckbot")'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && remoteAddress.network("qwantbot")'
|
||||
- 'userAgent.contains("+http://yandex.com/bots") && remoteAddress.network("yandexbot")'
|
||||
action: pass
|
||||
|
||||
# check a sequence of challenges
|
||||
- name: heavy-operations/0
|
||||
action: check
|
||||
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
|
||||
conditions: ['($is-heavy-resource)']
|
||||
- name: heavy-operations/1
|
||||
action: check
|
||||
challenges: [self-resource-load, js-pow-sha256, http-cookie-check]
|
||||
- name: heavy-operations
|
||||
conditions: ['($is-heavy-resource)']
|
||||
action: none
|
||||
children:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [ self-resource-load, js-pow-sha256, http-cookie-check ]
|
||||
settings:
|
||||
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
|
||||
|
||||
- name: standard-bots
|
||||
action: check
|
||||
challenges: [self-meta-refresh, self-resource-load]
|
||||
settings:
|
||||
challenges: [self-meta-refresh, self-resource-load]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
|
||||
@@ -433,15 +430,20 @@ rules:
|
||||
action: pass
|
||||
|
||||
# check DNSBL and serve harder challenges
|
||||
# todo: make this specific to score
|
||||
- name: undesired-dnsbl
|
||||
conditions:
|
||||
- 'inDNSBL(remoteAddress)'
|
||||
action: check
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
settings:
|
||||
challenges: [dnsbl]
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-pow-sha256, http-cookie-check]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
settings:
|
||||
challenges: [js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
@@ -453,19 +455,22 @@ rules:
|
||||
|
||||
- name: plaintext-browser
|
||||
action: challenge
|
||||
challenges: [http-cookie-check, self-meta-refresh, self-cookie]
|
||||
settings:
|
||||
challenges: [http-cookie-check, self-meta-refresh, self-cookie]
|
||||
conditions:
|
||||
- 'userAgent.startsWith("Lynx/")'
|
||||
|
||||
- name: standard-tools
|
||||
action: challenge
|
||||
challenges: [self-cookie]
|
||||
settings:
|
||||
challenges: [self-cookie]
|
||||
conditions:
|
||||
- '($is-tool-ua)'
|
||||
- '!($is-generic-browser)'
|
||||
|
||||
- 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)'
|
||||
|
@@ -44,65 +44,50 @@ networks:
|
||||
|
||||
challenges:
|
||||
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: 15
|
||||
runtime:
|
||||
mode: wasm
|
||||
# Verify must be under challenges/{name}/runtime/{asset}
|
||||
asset: runtime.wasm
|
||||
probability: 0.02
|
||||
# needs to be under static folder
|
||||
js-loader: load.mjs
|
||||
# needs to be under runtime folder
|
||||
wasm-runtime: runtime.wasm
|
||||
wasm-runtime-settings:
|
||||
difficulty: 15
|
||||
verify-probability: 0.02
|
||||
|
||||
# Challenges with a cookie, self redirect (non-JS, requires HTTP parsing)
|
||||
self-cookie:
|
||||
mode: "cookie"
|
||||
runtime: "cookie"
|
||||
|
||||
|
||||
# Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic)
|
||||
# Works on HTTP/2 and above!
|
||||
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: ""
|
||||
|
||||
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
|
||||
self-header-refresh:
|
||||
mode: "header-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "header"
|
||||
|
||||
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
|
||||
self-meta-refresh:
|
||||
mode: "meta-refresh"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
runtime: "refresh"
|
||||
parameters:
|
||||
refresh-via: "meta"
|
||||
|
||||
# Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic)
|
||||
self-resource-load:
|
||||
mode: "resource-load"
|
||||
runtime:
|
||||
# verifies that result = key
|
||||
mode: "key"
|
||||
probability: 0.1
|
||||
runtime: "resource-load"
|
||||
|
||||
dnsbl:
|
||||
runtime: dnsbl
|
||||
parameters:
|
||||
key-code: 200
|
||||
key-mime: text/css
|
||||
key-content: ""
|
||||
dnsbl-decay: 1h
|
||||
dnsbl-timeout: 1s
|
||||
|
||||
conditions:
|
||||
# Conditions will get replaced on rules AST when found as ($condition-name)
|
||||
@@ -198,7 +183,7 @@ rules:
|
||||
- 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")'
|
||||
# SEO / Ads and marketing
|
||||
- 'userAgent.contains("BLEXBot")'
|
||||
action: deny
|
||||
action: drop
|
||||
|
||||
- name: unknown-crawlers
|
||||
conditions:
|
||||
@@ -207,31 +192,31 @@ rules:
|
||||
action: deny
|
||||
|
||||
# check a sequence of challenges
|
||||
- name: suspicious-crawlers/0
|
||||
- name: suspicious-crawlers
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
- name: suspicious-crawlers/1
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-preload-link]
|
||||
- name: suspicious-crawlers/2
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-header-refresh]
|
||||
- name: suspicious-crawlers/3
|
||||
conditions: ['($is-suspicious-crawler)']
|
||||
action: check
|
||||
challenges: [self-resource-load]
|
||||
action: none
|
||||
children:
|
||||
- name: 0
|
||||
action: check
|
||||
settings:
|
||||
challenges: [js-pow-sha256]
|
||||
- name: 1
|
||||
action: check
|
||||
settings:
|
||||
challenges: [self-preload-link, self-resource-load]
|
||||
- name: 2
|
||||
action: check
|
||||
settings:
|
||||
challenges: [self-header-refresh]
|
||||
|
||||
- name: desired-crawlers
|
||||
conditions:
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)'
|
||||
- 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)'
|
||||
- 'userAgent.contains("+https://kagi.com/bot") && remoteAddress.network("kagibot")'
|
||||
- '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-PageRenderer") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && remoteAddress.network("googlebot")'
|
||||
- 'userAgent.contains("+http://www.bing.com/bingbot.htm") && remoteAddress.network("bingbot")'
|
||||
- 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && remoteAddress.network("duckduckbot")'
|
||||
- 'userAgent.contains("+https://help.qwant.com/bot/") && remoteAddress.network("qwantbot")'
|
||||
- 'userAgent.contains("+http://yandex.com/bots") && remoteAddress.network("yandexbot")'
|
||||
action: pass
|
||||
|
||||
- name: homesite
|
||||
@@ -240,15 +225,20 @@ rules:
|
||||
action: pass
|
||||
|
||||
# check DNSBL and serve harder challenges
|
||||
# todo: make this specific to score
|
||||
- name: undesired-dnsbl
|
||||
conditions:
|
||||
- 'inDNSBL(remoteAddress)'
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
settings:
|
||||
challenges: [dnsbl]
|
||||
# if DNSBL fails, check additional challenges
|
||||
fail: check
|
||||
fail-settings:
|
||||
challenges: [js-pow-sha256]
|
||||
|
||||
- name: suspicious-fetchers
|
||||
action: check
|
||||
challenges: [js-pow-sha256]
|
||||
settings:
|
||||
challenges: [js-pow-sha256]
|
||||
conditions:
|
||||
- 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")'
|
||||
|
||||
@@ -260,13 +250,15 @@ rules:
|
||||
|
||||
- name: plaintext-browser
|
||||
action: challenge
|
||||
challenges: [self-meta-refresh, self-cookie]
|
||||
settings:
|
||||
challenges: [self-meta-refresh, self-cookie]
|
||||
conditions:
|
||||
- 'userAgent.startsWith("Lynx/")'
|
||||
|
||||
- name: standard-tools
|
||||
action: challenge
|
||||
challenges: [self-cookie]
|
||||
settings:
|
||||
challenges: [self-cookie]
|
||||
conditions:
|
||||
- '($is-generic-robot-ua)'
|
||||
- '($is-tool-ua)'
|
||||
@@ -274,6 +266,7 @@ rules:
|
||||
|
||||
- name: standard-browser
|
||||
action: challenge
|
||||
challenges: [self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
settings:
|
||||
challenges: [self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
|
||||
conditions:
|
||||
- '($is-generic-browser)'
|
||||
|
5
go.mod
5
go.mod
@@ -6,16 +6,15 @@ toolchain go1.24.2
|
||||
|
||||
require (
|
||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/alphadose/haxmap v1.4.1
|
||||
github.com/go-jose/go-jose/v4 v4.1.0
|
||||
github.com/goccy/go-yaml v1.17.1
|
||||
github.com/google/cel-go v0.24.1
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/pires/go-proxyproto v0.8.0
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
golang.org/x/crypto v0.37.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
11
go.sum
11
go.sum
@@ -2,8 +2,8 @@ cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
|
||||
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756 h1:bDqEUEYt4UJy8mfLCZeJuXx+xNJvdqTbkE4Ci11NQYU=
|
||||
codeberg.org/meta/gzipped/v2 v2.0.0-20231111234332-aa70c3194756/go.mod h1:aJ/ghJW7viYfwZ6OizDst+uJgbb6r/Hvoqhmi1OPTTw=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
|
||||
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/cel-go v0.24.1 h1:jsBCtxG8mM5wiUJDSGUqU0K7Mtr3w7Eyv00rw4DiZxI=
|
||||
github.com/google/cel-go v0.24.1/go.mod h1:Hdf9TqOaTNSFQA1ybQaRqATVoK7m/zcf7IMhGXP5zI8=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -21,8 +23,6 @@ github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/my
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
||||
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -40,8 +40,6 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
@@ -58,7 +56,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
80
lib/action/backend.go
Normal file
80
lib/action/backend.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionPROXY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
params := ProxyDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Match != "" {
|
||||
expr, err := regexp.Compile(params.Match)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Proxy{
|
||||
Match: expr,
|
||||
Rewrite: params.Rewrite,
|
||||
Backend: params.Backend,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return Proxy{
|
||||
Backend: params.Backend,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var ProxyDefaultSettings = ProxySettings{}
|
||||
|
||||
type ProxySettings struct {
|
||||
Match string `yaml:"proxy-match"`
|
||||
Rewrite string `yaml:"proxy-rewrite"`
|
||||
Backend string `yaml:"proxy-backend"`
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
Match *regexp.Regexp
|
||||
Rewrite string
|
||||
Backend string
|
||||
}
|
||||
|
||||
func (a Proxy) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
backend := data.State.GetBackend(a.Backend)
|
||||
if backend == nil {
|
||||
return false, fmt.Errorf("backend for %s not found", a.Backend)
|
||||
}
|
||||
|
||||
if a.Match != nil {
|
||||
// rewrite query
|
||||
r.URL.Path = a.Match.ReplaceAllString(r.URL.Path, a.Rewrite)
|
||||
}
|
||||
|
||||
// set headers, ignore reply
|
||||
_ = done()
|
||||
backend.ServeHTTP(w, r)
|
||||
|
||||
return false, nil
|
||||
}
|
36
lib/action/block.go
Normal file
36
lib/action/block.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionBLOCK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Block{
|
||||
Code: http.StatusForbidden,
|
||||
RuleHash: ruleHash,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
Code int
|
||||
RuleHash string
|
||||
}
|
||||
|
||||
func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Info("request blocked")
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Connection", "close")
|
||||
w.WriteHeader(a.Code)
|
||||
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
|
||||
|
||||
return false, nil
|
||||
}
|
178
lib/action/challenge.go
Normal file
178
lib/action/challenge.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
i := func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node, cont bool) (Handler, error) {
|
||||
params := ChallengeDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Code == 0 {
|
||||
params.Code = state.Settings().ChallengeResponseCode
|
||||
}
|
||||
|
||||
var regs []*challenge.Registration
|
||||
for _, regName := range params.Challenges {
|
||||
if reg, ok := state.GetChallengeByName(regName); ok {
|
||||
regs = append(regs, reg)
|
||||
} else {
|
||||
return nil, fmt.Errorf("challenge %s not found", regName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(regs) == 0 {
|
||||
return nil, fmt.Errorf("no registered challenges found in rule %s", ruleName)
|
||||
}
|
||||
|
||||
passHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.PassAction))]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown pass action %s", params.PassAction)
|
||||
}
|
||||
|
||||
passActionHandler, err := passHandler(state, ruleName, ruleHash, params.PassSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
failHandler, ok := Register[policy.RuleAction(strings.ToUpper(params.FailAction))]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown pass action %s", params.FailAction)
|
||||
}
|
||||
|
||||
failActionHandler, err := failHandler(state, ruleName, ruleHash, params.FailSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Challenge{
|
||||
RuleHash: ruleHash,
|
||||
Code: params.Code,
|
||||
Continue: cont,
|
||||
Challenges: regs,
|
||||
|
||||
PassAction: passActionHandler,
|
||||
FailAction: failActionHandler,
|
||||
}, nil
|
||||
}
|
||||
Register[policy.RuleActionCHALLENGE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return i(state, ruleName, ruleHash, settings, false)
|
||||
}
|
||||
Register[policy.RuleActionCHECK] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return i(state, ruleName, ruleHash, settings, true)
|
||||
}
|
||||
}
|
||||
|
||||
var ChallengeDefaultSettings = ChallengeSettings{
|
||||
PassAction: string(policy.RuleActionPASS),
|
||||
FailAction: string(policy.RuleActionDENY),
|
||||
}
|
||||
|
||||
type ChallengeSettings struct {
|
||||
Code int `yaml:"http-code"`
|
||||
Challenges []string `yaml:"challenges"`
|
||||
|
||||
PassAction string `yaml:"pass"`
|
||||
PassSettings ast.Node `yaml:"pass-settings"`
|
||||
|
||||
// FailAction Executed in case no challenges match or
|
||||
FailAction string `yaml:"fail"`
|
||||
FailSettings ast.Node `yaml:"fail-settings"`
|
||||
}
|
||||
|
||||
type Challenge struct {
|
||||
RuleHash string
|
||||
Code int
|
||||
Continue bool
|
||||
Challenges []*challenge.Registration
|
||||
|
||||
PassAction Handler
|
||||
FailAction Handler
|
||||
}
|
||||
|
||||
func (a Challenge) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
for _, reg := range a.Challenges {
|
||||
if data.HasValidChallenge(reg.Id()) {
|
||||
if a.Continue {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// we passed!
|
||||
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
|
||||
}
|
||||
}
|
||||
// none matched, issue challenges in sequential priority
|
||||
for _, reg := range a.Challenges {
|
||||
result := data.ChallengeVerify[reg.Id()]
|
||||
state := data.ChallengeState[reg.Id()]
|
||||
if result.Ok() || result == challenge.VerifyResultSkip || state == challenge.VerifyStatePass {
|
||||
// skip already ok'd challenges for some reason (TODO: why)
|
||||
// also skip skipped challenges due to preconditions
|
||||
continue
|
||||
}
|
||||
|
||||
expiry := data.Expiration(reg.Duration)
|
||||
key := challenge.GetChallengeKeyForRequest(data.State, reg, expiry, r)
|
||||
data.State.ChallengeIssued(r, reg, r.URL.String(), logger)
|
||||
result = reg.IssueChallenge(w, r, key, expiry)
|
||||
data.ChallengeVerify[reg.Id()] = result
|
||||
data.ChallengeState[reg.Id()] = challenge.VerifyStatePass
|
||||
switch result {
|
||||
case challenge.VerifyResultOK:
|
||||
data.State.ChallengePassed(r, reg, r.URL.String(), logger)
|
||||
if a.Continue {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return a.PassAction.Handle(logger.With("challenge", reg.Name), w, r, done)
|
||||
case challenge.VerifyResultNotOK:
|
||||
// we have had the challenge checked, but it's not ok!
|
||||
// safe to continue
|
||||
continue
|
||||
case challenge.VerifyResultFail:
|
||||
err := fmt.Errorf("challenge %s failed on issuance", reg.Name)
|
||||
data.State.ChallengeFailed(r, reg, err, r.URL.String(), logger)
|
||||
|
||||
if reg.Class == challenge.ClassTransparent {
|
||||
// allow continuing transparent challenges
|
||||
continue
|
||||
}
|
||||
|
||||
return a.FailAction.Handle(logger, w, r, done)
|
||||
case challenge.VerifyResultNone:
|
||||
// challenge was issued
|
||||
if reg.Class == challenge.ClassTransparent {
|
||||
// allow continuing transparent challenges
|
||||
continue
|
||||
}
|
||||
// we cannot continue after issuance
|
||||
return false, nil
|
||||
|
||||
case challenge.VerifyResultSkip:
|
||||
// continue onto next one due to precondition
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// nothing matched, execute default action
|
||||
return a.FailAction.Handle(logger, w, r, done)
|
||||
}
|
47
lib/action/code.go
Normal file
47
lib/action/code.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionCODE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
params := CodeDefaultSettings
|
||||
|
||||
if settings != nil {
|
||||
ymlData, err := settings.MarshalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Code == 0 {
|
||||
return nil, errors.New("http-code not set")
|
||||
}
|
||||
|
||||
return Code(params.Code), nil
|
||||
}
|
||||
}
|
||||
|
||||
var CodeDefaultSettings = CodeSettings{}
|
||||
|
||||
type CodeSettings struct {
|
||||
Code int `yaml:"http-code"`
|
||||
}
|
||||
|
||||
type Code int
|
||||
|
||||
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
w.WriteHeader(int(a))
|
||||
return false, nil
|
||||
}
|
31
lib/action/deny.go
Normal file
31
lib/action/deny.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionDENY] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Deny{
|
||||
Code: http.StatusForbidden,
|
||||
RuleHash: ruleHash,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Deny struct {
|
||||
Code int
|
||||
RuleHash string
|
||||
}
|
||||
|
||||
func (a Deny) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Info("request denied")
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
data.State.ErrorPage(w, r, a.Code, fmt.Errorf("access denied: denied by administrative rule %s/%s", data.Id.String(), a.RuleHash), "")
|
||||
return false, nil
|
||||
}
|
39
lib/action/drop.go
Normal file
39
lib/action/drop.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionDROP] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Drop{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Drop struct {
|
||||
}
|
||||
|
||||
func (a Drop) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Info("request dropped")
|
||||
|
||||
if hj, ok := w.(http.Hijacker); ok {
|
||||
if conn, _, err := hj.Hijack(); err == nil {
|
||||
// drop without sending data
|
||||
_ = conn.Close()
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fallback
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
w.Header().Set("Connection", "close")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
return false, nil
|
||||
}
|
21
lib/action/none.go
Normal file
21
lib/action/none.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionNONE] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return None{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type None struct{}
|
||||
|
||||
func (a None) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
return true, nil
|
||||
}
|
23
lib/action/pass.go
Normal file
23
lib/action/pass.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register[policy.RuleActionPASS] = func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error) {
|
||||
return Pass{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Pass struct{}
|
||||
|
||||
func (a Pass) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||
logger.Debug("request passed")
|
||||
done().ServeHTTP(w, r)
|
||||
return false, nil
|
||||
}
|
20
lib/action/register.go
Normal file
20
lib/action/register.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
// Handle An incoming request.
|
||||
// If next is true, continue processing
|
||||
// If next is false, stop processing. If passing to a backend, done() must be called beforehand to set headers.
|
||||
Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error)
|
||||
}
|
||||
|
||||
type NewFunc func(state challenge.StateInterface, ruleName, ruleHash string, settings ast.Node) (Handler, error)
|
||||
|
||||
var Register = make(map[policy.RuleAction]NewFunc)
|
128
lib/challenge.go
128
lib/challenge.go
@@ -1,125 +1,13 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/cookie"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/dnsbl"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/http"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/preload-link"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/refresh"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/resource-load"
|
||||
_ "git.gammaspectra.live/git/go-away/lib/challenge/wasm"
|
||||
)
|
||||
|
||||
type ChallengeInformation struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result"`
|
||||
|
||||
Expiry *jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func getRequestScheme(r *http.Request) string {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
|
||||
return proto
|
||||
}
|
||||
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
|
||||
return "http"
|
||||
}
|
||||
|
||||
func getRequestAddress(r *http.Request, clientHeader string) net.IP {
|
||||
var ipStr string
|
||||
if clientHeader != "" {
|
||||
ipStr = r.Header.Get(clientHeader)
|
||||
}
|
||||
if ipStr != "" {
|
||||
// handle X-Forwarded-For
|
||||
ipStr = strings.Split(ipStr, ",")[0]
|
||||
}
|
||||
|
||||
// fallback
|
||||
if ipStr == "" {
|
||||
parts := strings.Split(r.RemoteAddr, ":")
|
||||
// drop port
|
||||
ipStr = strings.Join(parts[:len(parts)-1], ":")
|
||||
}
|
||||
ipStr = strings.Trim(ipStr, "[]")
|
||||
return net.ParseIP(ipStr)
|
||||
}
|
||||
|
||||
type ChallengeKey []byte
|
||||
|
||||
const ChallengeKeySize = sha256.Size
|
||||
|
||||
func (k *ChallengeKey) Set(flags ChallengeKeyFlags) {
|
||||
(*k)[0] |= uint8(flags)
|
||||
}
|
||||
func (k *ChallengeKey) Get(flags ChallengeKeyFlags) ChallengeKeyFlags {
|
||||
return ChallengeKeyFlags((*k)[0] & uint8(flags))
|
||||
}
|
||||
func (k *ChallengeKey) Unset(flags ChallengeKeyFlags) {
|
||||
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||
}
|
||||
|
||||
type ChallengeKeyFlags uint8
|
||||
|
||||
const (
|
||||
ChallengeKeyFlagIsIPv4 = ChallengeKeyFlags(1 << iota)
|
||||
)
|
||||
|
||||
func ChallengeKeyFromString(s string) (ChallengeKey, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != ChallengeKeySize {
|
||||
return nil, errors.New("invalid challenge key")
|
||||
}
|
||||
return ChallengeKey(b), nil
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeKeyForRequest(challengeName string, until time.Time, r *http.Request) ChallengeKey {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
address := data.RemoteAddress
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(challengeName))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(address.To16())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.publicKey)
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
sum := ChallengeKey(hasher.Sum(nil))
|
||||
|
||||
sum[0] = 0
|
||||
|
||||
if address.To4() != nil {
|
||||
// Is IPv4, mark
|
||||
sum.Set(ChallengeKeyFlagIsIPv4)
|
||||
}
|
||||
return ChallengeKey(sum)
|
||||
}
|
||||
// This file loads embedded challenge runtimes so their init() is called
|
||||
|
47
lib/challenge/awaiter.go
Normal file
47
lib/challenge/awaiter.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/alphadose/haxmap"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type awaiterCallback func(result VerifyResult)
|
||||
|
||||
type Awaiter[K ~string | ~int64 | ~uint64] haxmap.Map[K, awaiterCallback]
|
||||
|
||||
func NewAwaiter[T ~string | ~int64 | ~uint64]() *Awaiter[T] {
|
||||
return (*Awaiter[T])(haxmap.New[T, awaiterCallback]())
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Await(key T, ctx context.Context) VerifyResult {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var result atomic.Int64
|
||||
|
||||
a.m().Set(key, func(receivedResult VerifyResult) {
|
||||
result.Store(int64(receivedResult))
|
||||
cancel()
|
||||
})
|
||||
// cleanup
|
||||
defer a.m().Del(key)
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return VerifyResult(result.Load())
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Solve(key T, result VerifyResult) {
|
||||
if f, ok := a.m().GetAndDel(key); ok && f != nil {
|
||||
f(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) m() *haxmap.Map[T, awaiterCallback] {
|
||||
return (*haxmap.Map[T, awaiterCallback])(a)
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Close() error {
|
||||
return nil
|
||||
}
|
38
lib/challenge/cookie/cookie.go
Normal file
38
lib/challenge/cookie/cookie.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "cookie"
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
|
||||
uri, err := challenge.RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
171
lib/challenge/data.go
Normal file
171
lib/challenge/data.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/traits"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"time"
|
||||
)
|
||||
|
||||
type requestDataContextKey struct {
|
||||
}
|
||||
|
||||
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
return ctx.Value(requestDataContextKey{}).(*RequestData)
|
||||
}
|
||||
|
||||
type RequestId [16]byte
|
||||
|
||||
func (id RequestId) String() string {
|
||||
return hex.EncodeToString(id[:])
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id RequestId
|
||||
Time time.Time
|
||||
ChallengeVerify map[Id]VerifyResult
|
||||
ChallengeState map[Id]VerifyState
|
||||
RemoteAddress net.IP
|
||||
State StateInterface
|
||||
|
||||
r *http.Request
|
||||
|
||||
fp map[string]string
|
||||
header traits.Mapper
|
||||
query traits.Mapper
|
||||
}
|
||||
|
||||
func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *RequestData) {
|
||||
|
||||
var data RequestData
|
||||
// generate random id, todo: is this fast?
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
data.RemoteAddress = utils.GetRequestAddress(r, state.Settings().ClientIpHeader)
|
||||
data.ChallengeVerify = make(map[Id]VerifyResult, len(state.GetChallenges()))
|
||||
data.ChallengeState = make(map[Id]VerifyState, len(state.GetChallenges()))
|
||||
data.Time = time.Now().UTC()
|
||||
data.State = state
|
||||
data.r = r
|
||||
|
||||
data.fp = make(map[string]string, 2)
|
||||
|
||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||
ja3n := ja3nPtr.String()
|
||||
data.fp["ja3n"] = ja3n
|
||||
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||
ja4 := ja4Ptr.String()
|
||||
data.fp["ja4"] = ja4
|
||||
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
}
|
||||
|
||||
data.query = condition.NewValuesMap(r.URL.Query())
|
||||
data.header = condition.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
||||
|
||||
return r, &data
|
||||
}
|
||||
|
||||
func (d *RequestData) ResolveName(name string) (any, bool) {
|
||||
switch name {
|
||||
case "host":
|
||||
return d.r.Host, true
|
||||
case "method":
|
||||
return d.r.Method, true
|
||||
case "remoteAddress":
|
||||
return d.RemoteAddress, true
|
||||
case "userAgent":
|
||||
return d.r.UserAgent(), true
|
||||
case "path":
|
||||
return d.r.URL.Path, true
|
||||
case "query":
|
||||
return d.query, true
|
||||
case "headers":
|
||||
return d.header, true
|
||||
case "fp":
|
||||
return d.fp, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) Parent() cel.Activation {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
for _, reg := range d.State.GetChallenges() {
|
||||
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
// clear invalid cookie
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
}
|
||||
|
||||
// prevent evaluating the challenge if not solved
|
||||
if !verifyResult.Ok() && reg.Condition != nil {
|
||||
out, _, err := reg.Condition.Eval(d)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match due to precondition!
|
||||
verifyResult = VerifyResultSkip
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
d.ChallengeVerify[reg.Id()] = verifyResult
|
||||
d.ChallengeState[reg.Id()] = verifyState
|
||||
}
|
||||
|
||||
if d.State.Settings().BackendIpHeader != "" {
|
||||
if d.State.Settings().ClientIpHeader != "" {
|
||||
r.Header.Del(d.State.Settings().ClientIpHeader)
|
||||
}
|
||||
r.Header.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
|
||||
}
|
||||
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
}
|
||||
|
||||
func (d *RequestData) Expiration(duration time.Duration) time.Time {
|
||||
return d.Time.Add(duration).Round(duration)
|
||||
}
|
||||
|
||||
func (d *RequestData) HasValidChallenge(id Id) bool {
|
||||
return d.ChallengeVerify[id].Ok()
|
||||
}
|
||||
|
||||
func (d *RequestData) Headers(headers http.Header) {
|
||||
headers.Set("X-Away-Id", d.Id.String())
|
||||
|
||||
for id, result := range d.ChallengeVerify {
|
||||
if result.Ok() {
|
||||
c, ok := d.State.GetChallenge(id)
|
||||
if !ok {
|
||||
panic("challenge not found")
|
||||
}
|
||||
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
|
||||
}
|
||||
}
|
||||
}
|
149
lib/challenge/dnsbl/dnsbl.go
Normal file
149
lib/challenge/dnsbl/dnsbl.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "dnsbl"
|
||||
|
||||
type Parameters struct {
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
Host string `yaml:"dnsbl-host"`
|
||||
Timeout time.Duration `yaml:"dnsbl-timeout"`
|
||||
Decay time.Duration `yaml:"dnsbl-decay"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.10,
|
||||
Timeout: time.Second * 1,
|
||||
Decay: time.Hour * 1,
|
||||
Host: "dnsbl.dronebl.org",
|
||||
}
|
||||
|
||||
func lookup(ctx context.Context, decay, timeout time.Duration, dnsbl *utils.DNSBL, decayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse], ip net.IP) (utils.DNSBLResponse, error) {
|
||||
var key [net.IPv6len]byte
|
||||
copy(key[:], ip.To16())
|
||||
|
||||
result, ok := decayMap.Get(key)
|
||||
if ok {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
result, err := dnsbl.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
decayMap.Set(key, result, decay)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
type closer chan struct{}
|
||||
|
||||
func (c closer) Close() error {
|
||||
select {
|
||||
case <-c:
|
||||
default:
|
||||
close(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Host == "" {
|
||||
return errors.New("empty host")
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//20% default
|
||||
params.VerifyProbability = 0.20
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
decayMap := utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
||||
|
||||
dnsbl := utils.NewDNSBL(params.Host, &net.Resolver{
|
||||
PreferGo: true,
|
||||
})
|
||||
|
||||
ob := make(closer)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(params.Timeout / 3)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
decayMap.Decay()
|
||||
case <-ob:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// allow freeing the ticker/decay map
|
||||
reg.Object = ob
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress)
|
||||
if err != nil {
|
||||
data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.String(), "result", result, "err", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
if result.Bad() {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
164
lib/challenge/helper.go
Normal file
164
lib/challenge/helper.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func NewKeyVerifier() (verify VerifyFunc, issue func(key Key) string) {
|
||||
return func(key Key, token []byte, r *http.Request) (VerifyResult, error) {
|
||||
expectedKey, err := hex.DecodeString(string(token))
|
||||
if err != nil {
|
||||
return VerifyResultFail, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(key[:], expectedKey) == 1 {
|
||||
return VerifyResultOK, nil
|
||||
}
|
||||
return VerifyResultFail, errors.New("invalid token")
|
||||
}, func(key Key) string {
|
||||
return hex.EncodeToString(key[:])
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
QueryArgPrefix = "__goaway"
|
||||
QueryArgReferer = QueryArgPrefix + "_referer"
|
||||
QueryArgRedirect = QueryArgPrefix + "_redirect"
|
||||
QueryArgRequestId = QueryArgPrefix + "_id"
|
||||
QueryArgChallenge = QueryArgPrefix + "_challenge"
|
||||
QueryArgToken = QueryArgPrefix + "_token"
|
||||
)
|
||||
|
||||
const MakeChallengeUrlSuffix = "/make-challenge"
|
||||
const VerifyChallengeUrlSuffix = "/verify-challenge"
|
||||
|
||||
func GetVerifyInformation(r *http.Request, reg *Registration) (requestId RequestId, redirect, token string, err error) {
|
||||
|
||||
if r.FormValue(QueryArgChallenge) != reg.Name {
|
||||
return RequestId{}, "", "", fmt.Errorf("unexpected challenge: got %s", r.FormValue(QueryArgChallenge))
|
||||
}
|
||||
|
||||
requestIdHex := r.FormValue(QueryArgRequestId)
|
||||
|
||||
if len(requestId) != hex.DecodedLen(len(requestIdHex)) {
|
||||
return RequestId{}, "", "", errors.New("invalid request id")
|
||||
}
|
||||
n, err := hex.Decode(requestId[:], []byte(requestIdHex))
|
||||
if err != nil {
|
||||
return RequestId{}, "", "", err
|
||||
} else if n != len(requestId) {
|
||||
return RequestId{}, "", "", errors.New("invalid request id")
|
||||
}
|
||||
|
||||
token = r.FormValue(QueryArgToken)
|
||||
redirect, err = utils.EnsureNoOpenRedirect(r.FormValue(QueryArgRedirect))
|
||||
if err != nil {
|
||||
return RequestId{}, "", "", err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, error) {
|
||||
|
||||
redirectUrl, err := RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uri := new(url.URL)
|
||||
uri.Path = reg.Path + VerifyChallengeUrlSuffix
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
values.Set(QueryArgRedirect, redirectUrl.String())
|
||||
values.Set(QueryArgToken, token)
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
|
||||
uri, err := url.ParseRequestURI(r.URL.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
values.Set(QueryArgReferer, r.Referer())
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
|
||||
return
|
||||
} else if !verifyResult.Ok() {
|
||||
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFunc, responseFunc func(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string)) http.HandlerFunc {
|
||||
if verify == nil {
|
||||
verify = reg.Verify
|
||||
}
|
||||
if responseFunc == nil {
|
||||
responseFunc = VerifyHandlerChallengeResponseFunc
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
requestId, redirect, token, err := GetVerifyInformation(r, reg)
|
||||
if err != nil {
|
||||
state.ChallengeFailed(r, reg, err, "", nil)
|
||||
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("internal error: %w", err), "")
|
||||
return
|
||||
}
|
||||
data.Id = requestId
|
||||
|
||||
err = func() (err error) {
|
||||
expiration := data.Expiration(reg.Duration)
|
||||
key := GetChallengeKeyForRequest(state, reg, expiration, r)
|
||||
|
||||
verifyResult, err := verify(key, []byte(token), r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !verifyResult.Ok() {
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}
|
||||
|
||||
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
|
||||
}
|
||||
data.ChallengeVerify[reg.id] = verifyResult
|
||||
state.ChallengePassed(r, reg, redirect, nil)
|
||||
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
state.ChallengeFailed(r, reg, err, redirect, nil)
|
||||
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
158
lib/challenge/http/http.go
Normal file
158
lib/challenge/http/http.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "http"
|
||||
|
||||
type Parameters struct {
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
|
||||
HttpMethod string `yaml:"http-method"`
|
||||
HttpCode int `yaml:"http-code"`
|
||||
HttpCookie string `yaml:"http-cookie"`
|
||||
Url string `yaml:"http-url"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.20,
|
||||
HttpMethod: http.MethodGet,
|
||||
HttpCode: http.StatusOK,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Url == "" {
|
||||
return errors.New("empty url")
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
bindAuthValue := func(key challenge.Key, r *http.Request) ([]byte, error) {
|
||||
var cookieValue string
|
||||
if cookie, err := r.Cookie(params.HttpCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return nil, http.ErrNoCookie
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key[:])
|
||||
return sum.Sum(nil), nil
|
||||
}
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//20% default
|
||||
params.VerifyProbability = 0.20
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
if params.HttpCookie != "" {
|
||||
// re-verify the cookie value
|
||||
// TODO: configure to verify with backend
|
||||
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
|
||||
sum, err := bindAuthValue(key, r)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(sum, token) == 1 {
|
||||
return challenge.VerifyResultOK, nil
|
||||
}
|
||||
return challenge.VerifyResultFail, errors.New("invalid cookie value")
|
||||
}
|
||||
}
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
var sum []byte
|
||||
if params.HttpCookie != "" {
|
||||
if c, err := r.Cookie(params.HttpCookie); err != nil || c == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return challenge.VerifyResultSkip
|
||||
} else {
|
||||
sum, err = bindAuthValue(key, r)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
var excludeHeaders = []string{"Host", "Content-Length"}
|
||||
for k, v := range r.Header {
|
||||
if slices.Contains(excludeHeaders, k) {
|
||||
// skip these parameters
|
||||
continue
|
||||
}
|
||||
request.Header[k] = v
|
||||
}
|
||||
// set id
|
||||
request.Header.Set("X-Away-Id", challenge.RequestDataFromContext(r.Context()).Id.String())
|
||||
|
||||
// set request info in X headers
|
||||
request.Header.Set("X-Away-Host", r.Host)
|
||||
request.Header.Set("X-Away-Path", r.URL.Path)
|
||||
request.Header.Set("X-Away-Query", r.URL.RawQuery)
|
||||
|
||||
response, err := state.Client().Do(request)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
if response.StatusCode != params.HttpCode {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
80
lib/challenge/key.go
Normal file
80
lib/challenge/key.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Key [KeySize]byte
|
||||
|
||||
const KeySize = sha256.Size
|
||||
|
||||
func (k *Key) Set(flags KeyFlags) {
|
||||
(*k)[0] |= uint8(flags)
|
||||
}
|
||||
func (k *Key) Get(flags KeyFlags) KeyFlags {
|
||||
return KeyFlags((*k)[0] & uint8(flags))
|
||||
}
|
||||
func (k *Key) Unset(flags KeyFlags) {
|
||||
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||
}
|
||||
|
||||
type KeyFlags uint8
|
||||
|
||||
const (
|
||||
KeyFlagIsIPv4 = KeyFlags(1 << iota)
|
||||
)
|
||||
|
||||
func KeyFromString(s string) (Key, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
if len(b) != KeySize {
|
||||
return Key{}, errors.New("invalid challenge key")
|
||||
}
|
||||
return Key(b), nil
|
||||
}
|
||||
|
||||
func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until time.Time, r *http.Request) Key {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
address := data.RemoteAddress
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(reg.Name))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(address.To16())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PublicKey())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
sum := Key(hasher.Sum(nil))
|
||||
|
||||
sum[0] = 0
|
||||
|
||||
if address.To4() != nil {
|
||||
// Is IPv4, mark
|
||||
sum.Set(KeyFlagIsIPv4)
|
||||
}
|
||||
return Key(sum)
|
||||
}
|
128
lib/challenge/preload-link/preload-link.go
Normal file
128
lib/challenge/preload-link/preload-link.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package preload_link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "preload-link"
|
||||
|
||||
type Parameters struct {
|
||||
Deadline time.Duration `yaml:"preload-early-hint-deadline"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Deadline: time.Second * 3,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
ob := challenge.NewAwaiter[string]()
|
||||
|
||||
reg.Object = ob
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
// this only works on HTTP/2 and HTTP/3
|
||||
|
||||
if r.ProtoMajor < 2 {
|
||||
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
|
||||
if _, ok := w.(http.Pusher); !ok {
|
||||
return challenge.VerifyResultSkip
|
||||
}
|
||||
}
|
||||
|
||||
issuerKey := issuer(key)
|
||||
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuerKey)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
// remove redirect args
|
||||
values := uri.Query()
|
||||
values.Del(challenge.QueryArgRedirect)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
// Redirect URI must be absolute to work
|
||||
uri.Scheme = utils.GetRequestScheme(r)
|
||||
uri.Host = r.Host
|
||||
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", uri.String()))
|
||||
defer func() {
|
||||
// remove old header so it won't show on response!
|
||||
w.Header().Del("Link")
|
||||
}()
|
||||
w.WriteHeader(http.StatusEarlyHints)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), params.Deadline)
|
||||
defer cancel()
|
||||
if result := ob.Await(issuerKey, ctx); result.Ok() {
|
||||
// this should serve!
|
||||
return challenge.VerifyResultOK
|
||||
} else if result == challenge.VerifyResultNone {
|
||||
// we hit timeout
|
||||
return challenge.VerifyResultFail
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
|
||||
issuerKey := issuer(key)
|
||||
|
||||
_, _, token, err := challenge.GetVerifyInformation(r, reg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
verifyResult, _ := verifier(key, []byte(token), r)
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
ob.Solve(issuerKey, verifyResult)
|
||||
if !verifyResult.Ok() {
|
||||
// also give data on other failure when mismatched
|
||||
ob.Solve(token, verifyResult)
|
||||
}
|
||||
})
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
64
lib/challenge/refresh/refresh.go
Normal file
64
lib/challenge/refresh/refresh.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package refresh
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["refresh"] = FillRegistration
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Mode string `yaml:"refresh-mode"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Mode: "header",
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
if params.Mode == "meta" {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"Meta": map[string]string{
|
||||
"refresh": "0; url=" + uri.String(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "0; url="+uri.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, nil)
|
||||
}
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
252
lib/challenge/register.go
Normal file
252
lib/challenge/register.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/google/cel-go/cel"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Register map[Id]*Registration
|
||||
|
||||
func (r Register) Get(id Id) (*Registration, bool) {
|
||||
c, ok := r[id]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func (r Register) GetByName(name string) (*Registration, Id, bool) {
|
||||
for id, c := range r {
|
||||
if c.Name == name {
|
||||
return c, id, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
var idCounter Id
|
||||
|
||||
// DefaultDuration TODO: adjust
|
||||
const DefaultDuration = time.Hour * 24 * 7
|
||||
|
||||
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
|
||||
runtime, ok := Runtimes[pol.Runtime]
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
|
||||
}
|
||||
|
||||
reg := &Registration{
|
||||
Name: name,
|
||||
Path: path.Join(state.UrlPath(), "challenge", name),
|
||||
Duration: pol.Duration,
|
||||
}
|
||||
|
||||
if reg.Duration == 0 {
|
||||
reg.Duration = DefaultDuration
|
||||
}
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range pol.Conditions {
|
||||
if replacer != nil {
|
||||
cond = replacer.Replace(cond)
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling conditions: %v", err)
|
||||
}
|
||||
reg.Condition, err = condition.Program(state.ProgramEnv(), ast)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, oldId, ok := r.GetByName(reg.Name); ok {
|
||||
reg.id = oldId
|
||||
} else {
|
||||
idCounter++
|
||||
reg.id = idCounter
|
||||
}
|
||||
|
||||
err := runtime(state, reg, pol.Parameters)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error filling registration: %v", err)
|
||||
}
|
||||
r[reg.id] = reg
|
||||
return reg, reg.id, nil
|
||||
}
|
||||
|
||||
func (r Register) Add(c *Registration) Id {
|
||||
if _, oldId, ok := r.GetByName(c.Name); ok {
|
||||
c.id = oldId
|
||||
r[oldId] = c
|
||||
return oldId
|
||||
} else {
|
||||
idCounter++
|
||||
c.id = idCounter
|
||||
r[idCounter] = c
|
||||
return idCounter
|
||||
}
|
||||
}
|
||||
|
||||
type Registration struct {
|
||||
// id The assigned internal identifier
|
||||
id Id
|
||||
|
||||
// Name The unique name for this challenge
|
||||
Name string
|
||||
|
||||
// Class whether this challenge is transparent or otherwise
|
||||
Class Class
|
||||
|
||||
// Condition A CEL condition which is passed the same environment as general rules.
|
||||
// If nil, always true
|
||||
// If non-nil, must return true for this challenge to be allowed to be executed
|
||||
Condition cel.Program
|
||||
|
||||
// Path The url path that this challenge is hosted under for the Handler to be called.
|
||||
Path string
|
||||
|
||||
// Duration How long this challenge will be valid when passed
|
||||
Duration time.Duration
|
||||
|
||||
// Handler An HTTP handler for all requests coming on the Path
|
||||
// This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
|
||||
// Recommended to use http.ServeMux
|
||||
Handler http.Handler
|
||||
|
||||
// Verify Verify an issued token
|
||||
Verify VerifyFunc
|
||||
VerifyProbability float64
|
||||
|
||||
// IssueChallenge Issues a challenge to a request.
|
||||
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
|
||||
// TODO: have this return error as well
|
||||
IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
|
||||
|
||||
// Object used to handle state or similar
|
||||
// Can be nil if no state is needed
|
||||
// If non-nil must implement io.Closer even if there's nothing to do
|
||||
Object io.Closer
|
||||
}
|
||||
|
||||
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
|
||||
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Ok bool `json:"ok"`
|
||||
|
||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (reg Registration) Id() Id {
|
||||
return reg.id
|
||||
}
|
||||
|
||||
func (reg Registration) IssueChallengeToken(privateKey ed25519.PrivateKey, key Key, result []byte, until time.Time, ok bool) (token string, err error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: privateKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err = jwt.Signed(signer).Claims(Token{
|
||||
Name: reg.Name,
|
||||
Key: key[:],
|
||||
Result: result,
|
||||
Ok: ok,
|
||||
Expiry: jwt.NumericDate(until.Unix()),
|
||||
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
var ErrTokenExpired = errors.New("token: expired")
|
||||
|
||||
func (reg Registration) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey Key, r *http.Request) (VerifyResult, VerifyState, error) {
|
||||
cookie, err := r.Cookie(utils.CookiePrefix + reg.Name)
|
||||
if err != nil {
|
||||
return VerifyResultNone, VerifyStateNone, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return VerifyResultNone, VerifyStateNone, http.ErrNoCookie
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||
if err != nil {
|
||||
return VerifyResultFail, VerifyStateNone, err
|
||||
}
|
||||
|
||||
var i Token
|
||||
err = token.Claims(publicKey, &i)
|
||||
if err != nil {
|
||||
return VerifyResultFail, VerifyStateNone, err
|
||||
}
|
||||
|
||||
if i.Name != reg.Name {
|
||||
return VerifyResultFail, VerifyStateNone, errors.New("token invalid name")
|
||||
}
|
||||
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
||||
}
|
||||
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey[:], i.Key) != 0 {
|
||||
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if reg.Verify != nil {
|
||||
if rand.Float64() < reg.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := reg.Verify(expectedKey, i.Result, r); err != nil {
|
||||
return VerifyResultFail, VerifyStateFull, err
|
||||
} else if ok == VerifyResultNotOK {
|
||||
return VerifyResultNotOK, VerifyStateFull, nil
|
||||
} else if !ok.Ok() {
|
||||
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
||||
} else {
|
||||
return ok, VerifyStateFull, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !i.Ok {
|
||||
return VerifyResultNotOK, VerifyStateBrief, nil
|
||||
}
|
||||
return VerifyResultOK, VerifyStateBrief, nil
|
||||
}
|
||||
|
||||
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
|
||||
|
||||
var Runtimes = make(map[string]FillRegistration)
|
55
lib/challenge/resource-load/resource-load.go
Normal file
55
lib/challenge/resource-load/resource-load.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package resource_load
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["resource-load"] = FillRegistrationHeader
|
||||
}
|
||||
|
||||
func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
// self redirect!
|
||||
//TODO: adjust deadline
|
||||
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"HeaderTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", uri.String())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, func(state challenge.StateInterface, data *challenge.RequestData, w http.ResponseWriter, r *http.Request, verifyResult challenge.VerifyResult, err error, redirect string) {
|
||||
//TODO: add other types inside css that need to be loaded!
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
41
lib/challenge/script.go
Normal file
41
lib/challenge/script.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed script.mjs
|
||||
var scriptData []byte
|
||||
|
||||
var scriptTemplate = template.Must(template.New("script.mjs").Parse(string(scriptData)))
|
||||
|
||||
func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registration, params any, script string) {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
|
||||
paramData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
panic(err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = scriptTemplate.Execute(w, map[string]any{
|
||||
"Id": data.Id.String(),
|
||||
"Path": reg.Path,
|
||||
"Parameters": paramData,
|
||||
"Random": utils.CacheBust(),
|
||||
"Challenge": reg.Name,
|
||||
"ChallengeScript": script,
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
panic(err)
|
||||
}
|
||||
}
|
@@ -54,10 +54,11 @@ const u = (url = "", params = {}) => {
|
||||
setTimeout(() => {
|
||||
const redir = window.location.href;
|
||||
window.location.href = u("{{ .Path }}/verify-challenge", {
|
||||
result: result,
|
||||
redirect: redir,
|
||||
requestId: "{{ .Id }}",
|
||||
elapsedTime: t1 - t0,
|
||||
__goaway_token: result,
|
||||
__goaway_challenge: "{{ .Challenge }}",
|
||||
__goaway_redirect: redir,
|
||||
__goaway_id: "{{ .Id }}",
|
||||
__goaway_elapsedTime: t1 - t0,
|
||||
});
|
||||
}, 500);
|
||||
} catch (err) {
|
@@ -1,176 +0,0 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result int
|
||||
|
||||
const (
|
||||
// ResultStop Stop testing other challenges and return
|
||||
ResultStop = Result(iota)
|
||||
// ResultContinue Test next
|
||||
ResultContinue
|
||||
// ResultPass passed, return and proxy
|
||||
ResultPass
|
||||
)
|
||||
|
||||
type Id int
|
||||
|
||||
type Challenge struct {
|
||||
Id Id
|
||||
Program cel.Program
|
||||
Name string
|
||||
Path string
|
||||
|
||||
Verify func(key []byte, result string, r *http.Request) (bool, error)
|
||||
VerifyProbability float64
|
||||
|
||||
ServeStatic http.Handler
|
||||
|
||||
ServeChallenge func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) Result
|
||||
|
||||
ServeScript http.Handler
|
||||
ServeScriptPath string
|
||||
|
||||
ServeMakeChallenge http.Handler
|
||||
ServeVerifyChallenge http.Handler
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
|
||||
Expiry *jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (c Challenge) IssueChallengeToken(privateKey ed25519.PrivateKey, key, result []byte, until time.Time) (token string, err error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: privateKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expiry := jwt.NumericDate(until.Unix())
|
||||
notBefore := jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix())
|
||||
issuedAt := jwt.NumericDate(time.Now().UTC().Unix())
|
||||
|
||||
token, err = jwt.Signed(signer).Claims(Token{
|
||||
Name: c.Name,
|
||||
Key: key,
|
||||
Result: result,
|
||||
Expiry: &expiry,
|
||||
NotBefore: ¬Before,
|
||||
IssuedAt: &issuedAt,
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
type VerifyResult int
|
||||
|
||||
const (
|
||||
VerifyResultNONE = VerifyResult(iota)
|
||||
VerifyResultFAIL
|
||||
VerifyResultSKIP
|
||||
|
||||
// VerifyResultPASS Client just passed this challenge
|
||||
VerifyResultPASS
|
||||
VerifyResultOK
|
||||
VerifyResultBRIEF
|
||||
VerifyResultFULL
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r >= VerifyResultPASS
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
switch r {
|
||||
case VerifyResultNONE:
|
||||
return "NONE"
|
||||
case VerifyResultFAIL:
|
||||
return "FAIL"
|
||||
case VerifyResultSKIP:
|
||||
return "SKIP"
|
||||
case VerifyResultPASS:
|
||||
return "PASS"
|
||||
case VerifyResultOK:
|
||||
return "OK"
|
||||
case VerifyResultBRIEF:
|
||||
return "BRIEF"
|
||||
case VerifyResultFULL:
|
||||
return "FULL"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
|
||||
func (c Challenge) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey []byte, r *http.Request) (VerifyResult, error) {
|
||||
cookie, err := r.Cookie(utils.CookiePrefix + c.Name)
|
||||
if err != nil {
|
||||
return VerifyResultNONE, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return VerifyResultNONE, http.ErrNoCookie
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||
if err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
}
|
||||
|
||||
var i Token
|
||||
err = token.Claims(publicKey, &i)
|
||||
if err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
}
|
||||
|
||||
if i.Name != c.Name {
|
||||
return VerifyResultFAIL, errors.New("token invalid name")
|
||||
}
|
||||
if i.Expiry == nil && i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFAIL, errors.New("token expired")
|
||||
}
|
||||
if i.NotBefore == nil && i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFAIL, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey, i.Key) != 0 {
|
||||
return VerifyResultFAIL, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if c.Verify != nil {
|
||||
if rand.Float64() < c.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := c.Verify(expectedKey, string(i.Result), r); err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
} else if !ok {
|
||||
return VerifyResultFAIL, ErrVerifyVerifyMismatch
|
||||
}
|
||||
return VerifyResultFULL, nil
|
||||
} else {
|
||||
return VerifyResultBRIEF, nil
|
||||
}
|
||||
}
|
||||
return VerifyResultOK, nil
|
||||
}
|
112
lib/challenge/types.go
Normal file
112
lib/challenge/types.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Id int64
|
||||
|
||||
type Class uint8
|
||||
|
||||
const (
|
||||
// ClassTransparent Transparent challenges work inline in the execution process.
|
||||
// These can pass or continue, so more challenges or requests can ve served afterward.
|
||||
ClassTransparent = Class(iota)
|
||||
|
||||
// ClassBlocking Blocking challenges must serve a different response to challenge the requester.
|
||||
// These can pass or stop, for example, due to serving a challenge
|
||||
ClassBlocking
|
||||
)
|
||||
|
||||
type VerifyState uint8
|
||||
|
||||
const (
|
||||
VerifyStateNone = VerifyState(iota)
|
||||
// VerifyStatePass Challenge was just passed on this request
|
||||
VerifyStatePass
|
||||
// VerifyStateBrief Challenge token was verified but didn't check the challenge
|
||||
VerifyStateBrief
|
||||
// VerifyStateFull Challenge token was verified and challenge verification was done
|
||||
VerifyStateFull
|
||||
)
|
||||
|
||||
func (r VerifyState) String() string {
|
||||
switch r {
|
||||
case VerifyStatePass:
|
||||
return "PASS"
|
||||
case VerifyStateBrief:
|
||||
return "BRIEF"
|
||||
case VerifyStateFull:
|
||||
return "FULL"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
type VerifyResult uint8
|
||||
|
||||
const (
|
||||
// VerifyResultNone A negative pass result, without a token
|
||||
VerifyResultNone = VerifyResult(iota)
|
||||
// VerifyResultFail A negative pass result, with an invalid token
|
||||
VerifyResultFail
|
||||
// VerifyResultSkip Challenge was skipped due to precondition
|
||||
VerifyResultSkip
|
||||
// VerifyResultNotOK A negative pass result, with a valid token
|
||||
VerifyResultNotOK
|
||||
|
||||
// VerifyResultOK A positive pass result, with a valid token
|
||||
VerifyResultOK
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r >= VerifyResultOK
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
switch r {
|
||||
case VerifyResultNone:
|
||||
return "None"
|
||||
case VerifyResultFail:
|
||||
return "Fail"
|
||||
case VerifyResultSkip:
|
||||
return "Skip"
|
||||
case VerifyResultNotOK:
|
||||
return "NotOK"
|
||||
case VerifyResultOK:
|
||||
return "OK"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
type StateInterface interface {
|
||||
ProgramEnv() *cel.Env
|
||||
|
||||
Client() *http.Client
|
||||
PrivateKey() ed25519.PrivateKey
|
||||
PublicKey() ed25519.PublicKey
|
||||
|
||||
UrlPath() string
|
||||
|
||||
ChallengeFailed(r *http.Request, reg *Registration, err error, redirect string, logger *slog.Logger)
|
||||
ChallengePassed(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||
ChallengeIssued(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||
|
||||
Logger(r *http.Request) *slog.Logger
|
||||
|
||||
ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *Registration, params map[string]any)
|
||||
ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string)
|
||||
|
||||
GetChallenge(id Id) (*Registration, bool)
|
||||
GetChallengeByName(name string) (*Registration, bool)
|
||||
GetChallenges() Register
|
||||
|
||||
Settings() policy.Settings
|
||||
|
||||
GetBackend(host string) http.Handler
|
||||
}
|
@@ -111,6 +111,7 @@ type VerifyChallengeInput struct {
|
||||
|
||||
type VerifyChallengeOutput uint64
|
||||
|
||||
// TODO: expand allowed values
|
||||
const (
|
||||
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
|
||||
VerifyChallengeOutputFailed
|
||||
|
186
lib/challenge/wasm/registration.go
Normal file
186
lib/challenge/wasm/registration.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package wasm
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
_interface "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["js"] = FillJavaScriptRegistration
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Path string `yaml:"path"`
|
||||
// Loader path to js/mjs file to use as challenge issuer
|
||||
Loader string `yaml:"js-loader"`
|
||||
|
||||
// Runtime path to WASM wasip1 runtime
|
||||
Runtime string `yaml:"wasm-runtime"`
|
||||
|
||||
Settings map[string]string `yaml:"wasm-runtime-settings"`
|
||||
|
||||
NativeCompiler bool `yaml:"wasm-native-compiler"`
|
||||
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.1,
|
||||
NativeCompiler: true,
|
||||
}
|
||||
|
||||
func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if params.Path == "" {
|
||||
params.Path = reg.Name
|
||||
}
|
||||
|
||||
assetsFs, err := embed.GetFallbackFS(embed.ChallengeFs, params.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//10% default
|
||||
params.VerifyProbability = 0.1
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
ob := NewRunner(params.NativeCompiler)
|
||||
reg.Object = ob
|
||||
|
||||
wasmData, err := assetsFs.ReadFile(path.Join("runtime", params.Runtime))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load runtime: %w", err)
|
||||
}
|
||||
|
||||
err = ob.Compile("runtime", wasmData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling runtime: %w", err)
|
||||
}
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"EndTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.CacheBust())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
|
||||
var ok bool
|
||||
err = ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
|
||||
in := _interface.VerifyChallengeInput{
|
||||
Key: key[:],
|
||||
Parameters: params.Settings,
|
||||
Result: token,
|
||||
}
|
||||
|
||||
out, err := VerifyChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == _interface.VerifyChallengeOutputError {
|
||||
return errors.New("error checking challenge")
|
||||
}
|
||||
ok = out == _interface.VerifyChallengeOutputOK
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail, err
|
||||
}
|
||||
if ok {
|
||||
return challenge.VerifyResultOK, nil
|
||||
}
|
||||
return challenge.VerifyResultFail, nil
|
||||
}
|
||||
|
||||
// serve assets if existent
|
||||
if staticFs, err := fs.Sub(assetsFs, "static"); err != nil {
|
||||
return fmt.Errorf("no static assets: %w", err)
|
||||
} else {
|
||||
mux.Handle("GET "+reg.Path+"/static/", http.StripPrefix(reg.Path+"/static/", gzipped.FileServer(gzipped.FS(staticFs))))
|
||||
}
|
||||
|
||||
mux.HandleFunc(reg.Path+challenge.MakeChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
err := ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
|
||||
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
|
||||
|
||||
in := _interface.MakeChallengeInput{
|
||||
Key: key[:],
|
||||
Parameters: params.Settings,
|
||||
Headers: inline.MIMEHeader(r.Header),
|
||||
}
|
||||
in.Data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := MakeChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set output headers
|
||||
for k, v := range out.Headers {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+"/script.mjs", func(w http.ResponseWriter, r *http.Request) {
|
||||
challenge.ServeChallengeScript(w, r, reg, params.Settings, path.Join(reg.Path, "static", params.Loader))
|
||||
})
|
||||
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
@@ -94,11 +94,13 @@ func (r *Runner) Compile(key string, binary []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) Close() {
|
||||
func (r *Runner) Close() error {
|
||||
for _, module := range r.modules {
|
||||
module.Close(r.context)
|
||||
if err := module.Close(r.context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.runtime.Close(r.context)
|
||||
return r.runtime.Close(r.context)
|
||||
}
|
||||
|
||||
var ErrModuleNotFound = errors.New("module not found")
|
||||
|
@@ -3,6 +3,12 @@ package condition
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"github.com/google/cel-go/ext"
|
||||
"github.com/yl2chen/cidranger"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,6 +21,124 @@ const (
|
||||
OperatorAnd = "&&"
|
||||
)
|
||||
|
||||
func NewRulesEnvironment(networks map[string]cidranger.Ranger) (*cel.Env, error) {
|
||||
|
||||
return cel.NewEnv(
|
||||
ext.Strings(
|
||||
ext.StringsLocale("en_US"),
|
||||
ext.StringsValidateFormatCalls(true),
|
||||
),
|
||||
cel.DefaultUTCTimeZone(true),
|
||||
//TODO: custom type for remoteAddress
|
||||
cel.Variable("remoteAddress", cel.BytesType),
|
||||
cel.Variable("host", cel.StringType),
|
||||
cel.Variable("method", cel.StringType),
|
||||
cel.Variable("userAgent", cel.StringType),
|
||||
cel.Variable("path", cel.StringType),
|
||||
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
||||
cel.Variable("fp", cel.MapType(cel.StringType, cel.StringType)),
|
||||
// http.Header
|
||||
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
||||
//TODO: dynamic type?
|
||||
cel.Function("inDNSBL",
|
||||
cel.Overload("inDNSBL_ip",
|
||||
[]*cel.Type{cel.AnyType},
|
||||
cel.BoolType,
|
||||
cel.UnaryBinding(func(val ref.Val) ref.Val {
|
||||
slog.Error("inDNSBL function has been deprecated, replace with dnsbl challenge")
|
||||
return types.Bool(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
cel.Function("network",
|
||||
cel.MemberOverload("netIP_network_string",
|
||||
[]*cel.Type{cel.BytesType, cel.StringType},
|
||||
cel.BoolType,
|
||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||
var ip net.IP
|
||||
switch v := lhs.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", lhs.Value()))
|
||||
}
|
||||
|
||||
val, ok := rhs.Value().(string)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("invalid network value %v", rhs.Value()))
|
||||
}
|
||||
|
||||
network, ok := networks[val]
|
||||
if !ok {
|
||||
_, ipNet, err := net.ParseCIDR(val)
|
||||
if err != nil {
|
||||
panic("network not found")
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return types.Bool(ok)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
cel.Function("inNetwork",
|
||||
cel.Overload("inNetwork_string_ip",
|
||||
[]*cel.Type{cel.StringType, cel.BytesType},
|
||||
cel.BoolType,
|
||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||
var ip net.IP
|
||||
switch v := rhs.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
|
||||
}
|
||||
|
||||
val, ok := lhs.Value().(string)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("invalid value %v", lhs.Value()))
|
||||
}
|
||||
slog.Debug(fmt.Sprintf("inNetwork function has been deprecated and will be removed in a future release, use remoteAddress.network(\"%s\") instead", val))
|
||||
|
||||
network, ok := networks[val]
|
||||
if !ok {
|
||||
_, ipNet, err := net.ParseCIDR(val)
|
||||
if err != nil {
|
||||
panic("network not found")
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return types.Bool(ok)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Program(env *cel.Env, ast *cel.Ast) (cel.Program, error) {
|
||||
return env.Program(ast,
|
||||
cel.EvalOptions(cel.OptOptimize),
|
||||
)
|
||||
}
|
||||
|
||||
func FromStrings(env *cel.Env, operator string, conditions ...string) (*cel.Ast, error) {
|
||||
var asts []*cel.Ast
|
||||
for _, c := range conditions {
|
||||
|
158
lib/condition/map.go
Normal file
158
lib/condition/map.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package condition
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"github.com/google/cel-go/common/types/traits"
|
||||
"net/textproto"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mimeLike struct {
|
||||
m textproto.MIMEHeader
|
||||
}
|
||||
|
||||
func (a mimeLike) ConvertToNative(typeDesc reflect.Type) (any, error) {
|
||||
return nil, fmt.Errorf("type conversion error from map to '%v'", typeDesc)
|
||||
}
|
||||
|
||||
func (a mimeLike) ConvertToType(typeVal ref.Type) ref.Val {
|
||||
switch typeVal {
|
||||
case types.MapType:
|
||||
return a
|
||||
case types.TypeType:
|
||||
return types.MapType
|
||||
}
|
||||
return types.NewErr("type conversion error from '%s' to '%s'", types.MapType, typeVal)
|
||||
}
|
||||
|
||||
func (a mimeLike) Equal(other ref.Val) ref.Val {
|
||||
return types.Bool(false)
|
||||
}
|
||||
|
||||
func (a mimeLike) Type() ref.Type {
|
||||
return types.MapType
|
||||
}
|
||||
|
||||
func (a mimeLike) Value() any {
|
||||
return a.m
|
||||
}
|
||||
|
||||
func (a mimeLike) Contains(key ref.Val) ref.Val {
|
||||
_, found := a.Find(key)
|
||||
return types.Bool(found)
|
||||
}
|
||||
|
||||
func (a mimeLike) Get(key ref.Val) ref.Val {
|
||||
v, found := a.Find(key)
|
||||
if !found {
|
||||
return types.ValOrErr(v, "no such key: %v", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (a mimeLike) Iterator() traits.Iterator {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (a mimeLike) IsZeroValue() bool {
|
||||
return len(a.m) == 0
|
||||
}
|
||||
|
||||
func (a mimeLike) Size() ref.Val {
|
||||
return types.Int(len(a.m))
|
||||
}
|
||||
|
||||
func (a mimeLike) Find(key ref.Val) (ref.Val, bool) {
|
||||
k, ok := key.(types.String)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return singleVal(a.m.Values(string(k)), true)
|
||||
}
|
||||
|
||||
type valuesLike struct {
|
||||
m map[string][]string
|
||||
}
|
||||
|
||||
func (a valuesLike) ConvertToNative(typeDesc reflect.Type) (any, error) {
|
||||
return nil, fmt.Errorf("type conversion error from map to '%v'", typeDesc)
|
||||
}
|
||||
|
||||
func (a valuesLike) ConvertToType(typeVal ref.Type) ref.Val {
|
||||
switch typeVal {
|
||||
case types.MapType:
|
||||
return a
|
||||
case types.TypeType:
|
||||
return types.MapType
|
||||
}
|
||||
return types.NewErr("type conversion error from '%s' to '%s'", types.MapType, typeVal)
|
||||
}
|
||||
|
||||
func (a valuesLike) Equal(other ref.Val) ref.Val {
|
||||
return types.Bool(false)
|
||||
}
|
||||
|
||||
func (a valuesLike) Type() ref.Type {
|
||||
return types.MapType
|
||||
}
|
||||
|
||||
func (a valuesLike) Value() any {
|
||||
return a.m
|
||||
}
|
||||
|
||||
func (a valuesLike) Contains(key ref.Val) ref.Val {
|
||||
_, found := a.Find(key)
|
||||
return types.Bool(found)
|
||||
}
|
||||
|
||||
func (a valuesLike) Get(key ref.Val) ref.Val {
|
||||
v, found := a.Find(key)
|
||||
if !found {
|
||||
return types.ValOrErr(v, "no such key: %v", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (a valuesLike) Iterator() traits.Iterator {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (a valuesLike) IsZeroValue() bool {
|
||||
return len(a.m) == 0
|
||||
}
|
||||
|
||||
func (a valuesLike) Size() ref.Val {
|
||||
return types.Int(len(a.m))
|
||||
}
|
||||
|
||||
func (a valuesLike) Find(key ref.Val) (ref.Val, bool) {
|
||||
k, ok := key.(types.String)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
val, ok := a.m[string(k)]
|
||||
return singleVal(val, ok)
|
||||
}
|
||||
|
||||
func singleVal(values []string, ok bool) (ref.Val, bool) {
|
||||
if len(values) == 0 || !ok {
|
||||
return nil, false
|
||||
}
|
||||
if len(values) > 1 {
|
||||
return types.String(strings.Join(values, ",")), true
|
||||
}
|
||||
return types.String(values[0]), true
|
||||
}
|
||||
|
||||
func NewMIMEMap(m textproto.MIMEHeader) traits.Mapper {
|
||||
return mimeLike{m: m}
|
||||
}
|
||||
|
||||
func NewValuesMap(m map[string][]string) traits.Mapper {
|
||||
return mimeLike{m: m}
|
||||
}
|
@@ -1,118 +1,11 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
)
|
||||
|
||||
func (state *State) initConditions() (err error) {
|
||||
state.RulesEnv, err = cel.NewEnv(
|
||||
cel.DefaultUTCTimeZone(true),
|
||||
cel.Variable("remoteAddress", cel.BytesType),
|
||||
cel.Variable("host", cel.StringType),
|
||||
cel.Variable("method", cel.StringType),
|
||||
cel.Variable("userAgent", cel.StringType),
|
||||
cel.Variable("path", cel.StringType),
|
||||
cel.Variable("query", cel.MapType(cel.StringType, cel.StringType)),
|
||||
cel.Variable("fpJA3N", cel.StringType),
|
||||
cel.Variable("fpJA4", cel.StringType),
|
||||
// http.Header
|
||||
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
|
||||
//TODO: dynamic type?
|
||||
cel.Function("inDNSBL",
|
||||
cel.Overload("inDNSBL_ip",
|
||||
[]*cel.Type{cel.AnyType},
|
||||
cel.BoolType,
|
||||
cel.UnaryBinding(func(val ref.Val) ref.Val {
|
||||
if state.Settings.DNSBL == nil {
|
||||
return types.Bool(false)
|
||||
}
|
||||
|
||||
var ip net.IP
|
||||
switch v := val.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", val.Value()))
|
||||
}
|
||||
|
||||
var key [net.IPv6len]byte
|
||||
copy(key[:], ip.To16())
|
||||
|
||||
result, ok := state.DecayMap.Get(key)
|
||||
if ok {
|
||||
return types.Bool(result.Bad())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result, err := state.Settings.DNSBL.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
slog.Debug("dnsbl lookup failed", "address", ip.String(), "result", result, "err", err)
|
||||
} else {
|
||||
slog.Debug("dnsbl lookup", "address", ip.String(), "result", result)
|
||||
}
|
||||
//TODO: configure decay
|
||||
state.DecayMap.Set(key, result, time.Hour)
|
||||
|
||||
return types.Bool(result.Bad())
|
||||
}),
|
||||
),
|
||||
),
|
||||
cel.Function("inNetwork",
|
||||
cel.Overload("inNetwork_string_ip",
|
||||
[]*cel.Type{cel.StringType, cel.AnyType},
|
||||
cel.BoolType,
|
||||
cel.BinaryBinding(func(lhs ref.Val, rhs ref.Val) ref.Val {
|
||||
var ip net.IP
|
||||
switch v := rhs.Value().(type) {
|
||||
case []byte:
|
||||
ip = v
|
||||
case net.IP:
|
||||
ip = v
|
||||
case string:
|
||||
ip = net.ParseIP(v)
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
panic(fmt.Errorf("invalid ip %v", rhs.Value()))
|
||||
}
|
||||
|
||||
val, ok := lhs.Value().(string)
|
||||
if !ok {
|
||||
panic(fmt.Errorf("invalid value %v", lhs.Value()))
|
||||
}
|
||||
|
||||
network, ok := state.Networks[val]
|
||||
if !ok {
|
||||
_, ipNet, err := net.ParseCIDR(val)
|
||||
if err != nil {
|
||||
panic("network not found")
|
||||
}
|
||||
return types.Bool(ipNet.Contains(ip))
|
||||
} else {
|
||||
ok, err := network.Contains(ip)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return types.Bool(ok)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
state.programEnv, err = condition.NewRulesEnvironment(state.networks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
499
lib/http.go
499
lib/http.go
@@ -1,28 +1,16 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/action"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -30,20 +18,11 @@ import (
|
||||
|
||||
var templates map[string]*template.Template
|
||||
|
||||
var cacheBust string
|
||||
|
||||
// DefaultValidity TODO: adjust
|
||||
const DefaultValidity = time.Hour * 24 * 7
|
||||
|
||||
func init() {
|
||||
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
dir, err := embed.TemplatesFs.ReadDir("templates")
|
||||
dir, err := embed.TemplatesFs.ReadDir(".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -51,7 +30,7 @@ func init() {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
data, err := embed.TemplatesFs.ReadFile(filepath.Join("templates", e.Name()))
|
||||
data, err := embed.TemplatesFs.ReadFile(e.Name())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -72,69 +51,16 @@ func initTemplate(name, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) challengePage(w http.ResponseWriter, id string, status int, challenge string, params map[string]any) error {
|
||||
input := make(map[string]any)
|
||||
input["Id"] = id
|
||||
input["Random"] = cacheBust
|
||||
input["Challenge"] = challenge
|
||||
input["Path"] = state.UrlPath
|
||||
input["Theme"] = state.Settings.ChallengeTemplateTheme
|
||||
|
||||
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, id, http.StatusInternalServerError, err, "")
|
||||
} else {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) errorPage(w http.ResponseWriter, id string, status int, err error, redirect string) 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{
|
||||
"Id": id,
|
||||
"Random": cacheBust,
|
||||
"Error": err.Error(),
|
||||
"Path": state.UrlPath,
|
||||
"Theme": state.Settings.ChallengeTemplateTheme,
|
||||
"Title": "Oh no! " + http.StatusText(status),
|
||||
"HideSpinner": true,
|
||||
"Challenge": "",
|
||||
"Redirect": redirect,
|
||||
})
|
||||
if err2 != nil {
|
||||
panic(err2)
|
||||
} else {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *State) addTiming(w http.ResponseWriter, name, desc string, duration time.Duration) {
|
||||
if state.Settings.Debug {
|
||||
if state.Settings().Debug {
|
||||
w.Header().Add("Server-Timing", fmt.Sprintf("%s;desc=%s;dur=%d", name, strconv.Quote(desc), duration.Milliseconds()))
|
||||
}
|
||||
}
|
||||
|
||||
func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
args := []any{
|
||||
"request_id", hex.EncodeToString(data.Id[:]),
|
||||
"request_id", data.Id.String(),
|
||||
"remote_address", data.RemoteAddress.String(),
|
||||
"user_agent", r.UserAgent(),
|
||||
"host", r.Host,
|
||||
@@ -153,272 +79,95 @@ func GetLoggerForRequest(r *http.Request) *slog.Logger {
|
||||
return slog.With(args...)
|
||||
}
|
||||
|
||||
func (state *State) logger(r *http.Request) *slog.Logger {
|
||||
return GetLoggerForRequest(r)
|
||||
}
|
||||
|
||||
func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
backend, ok := state.Settings.Backends[host]
|
||||
if !ok {
|
||||
backend := state.GetBackend(host)
|
||||
if backend == nil {
|
||||
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
lg := state.logger(r)
|
||||
lg := state.Logger(r)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
state.addTiming(w, "rule-env", "Setup the rule environment", time.Since(start))
|
||||
|
||||
var (
|
||||
ruleEvalDuration time.Duration
|
||||
)
|
||||
|
||||
serve := func() {
|
||||
state.addTiming(w, "rule-eval", "Evaluate access rules", ruleEvalDuration)
|
||||
backend.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
fail := func(code int, err error) {
|
||||
state.addTiming(w, "rule-eval", "Evaluate access rules", ruleEvalDuration)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), code, err, "")
|
||||
}
|
||||
|
||||
setAwayState := func(rule RuleState) {
|
||||
r.Header.Set("X-Away-Rule", rule.Name)
|
||||
r.Header.Set("X-Away-Hash", rule.Hash)
|
||||
r.Header.Set("X-Away-Action", string(rule.Action))
|
||||
data.Headers(state, r.Header)
|
||||
}
|
||||
|
||||
for _, rule := range state.Rules {
|
||||
// skip rules that have host match
|
||||
if rule.Host != nil && *rule.Host != host {
|
||||
continue
|
||||
cleanupRequest := func(r *http.Request, fromChallenge bool) {
|
||||
if fromChallenge {
|
||||
r.Header.Del("Referer")
|
||||
}
|
||||
if ref := r.FormValue(challenge.QueryArgReferer); ref != "" {
|
||||
r.Header.Set("Referer", ref)
|
||||
}
|
||||
start = time.Now()
|
||||
out, _, err := rule.Program.Eval(data.ProgramEnv)
|
||||
ruleEvalDuration += time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
fail(http.StatusInternalServerError, err)
|
||||
lg.Error(err.Error(), "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
panic(err)
|
||||
return
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) == types.True {
|
||||
switch rule.Action {
|
||||
default:
|
||||
panic(fmt.Errorf("unknown action %s", rule.Action))
|
||||
case policy.RuleActionPASS:
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
setAwayState(rule)
|
||||
serve()
|
||||
return
|
||||
case policy.RuleActionCHALLENGE, policy.RuleActionCHECK:
|
||||
for _, challengeId := range rule.Challenges {
|
||||
if result := data.Challenges[challengeId]; !result.Ok() {
|
||||
continue
|
||||
} else {
|
||||
if rule.Action == policy.RuleActionCHECK {
|
||||
goto nextRule
|
||||
}
|
||||
|
||||
// we passed the challenge!
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", state.Challenges[challengeId].Name)
|
||||
setAwayState(rule)
|
||||
serve()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// none matched, issue first challenge in priority
|
||||
for _, challengeId := range rule.Challenges {
|
||||
result := data.Challenges[challengeId]
|
||||
if result.Ok() || result == challenge.VerifyResultSKIP {
|
||||
// skip already ok'd challenges for some reason, and also skip skipped challenges
|
||||
continue
|
||||
}
|
||||
c := state.Challenges[challengeId]
|
||||
if c.ServeChallenge != nil {
|
||||
result := c.ServeChallenge(w, r, state.GetChallengeKeyForRequest(c.Name, data.Expires, r), data.Expires)
|
||||
switch result {
|
||||
case challenge.ResultStop:
|
||||
lg.Info("request challenged", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
return
|
||||
case challenge.ResultContinue:
|
||||
continue
|
||||
case challenge.ResultPass:
|
||||
if rule.Action == policy.RuleActionCHECK {
|
||||
goto nextRule
|
||||
}
|
||||
state.logger(r).Warn("challenge passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
|
||||
// set pass if caller didn't set one
|
||||
if !data.Challenges[c.Id].Ok() {
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
}
|
||||
|
||||
// we pass the challenge early!
|
||||
lg.Debug("request passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
|
||||
setAwayState(rule)
|
||||
serve()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
panic("challenge not found")
|
||||
}
|
||||
}
|
||||
case policy.RuleActionDENY:
|
||||
lg.Info("request denied", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
//TODO: config error code
|
||||
fail(http.StatusForbidden, fmt.Errorf("access denied: denied by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
|
||||
return
|
||||
case policy.RuleActionBLOCK:
|
||||
lg.Info("request blocked", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
//TODO: config error code
|
||||
//TODO: configure block
|
||||
fail(http.StatusForbidden, fmt.Errorf("access denied: blocked by administrative rule %s/%s", r.Header.Get("X-Away-Id"), rule.Hash))
|
||||
return
|
||||
case policy.RuleActionPOISON:
|
||||
lg.Info("request poisoned", "rule", rule.Name, "rule_hash", rule.Hash)
|
||||
|
||||
mime := "text/html"
|
||||
switch path.Ext(r.URL.Path) {
|
||||
case ".css":
|
||||
case ".json", ".js", ".mjs":
|
||||
|
||||
}
|
||||
|
||||
encodings := strings.Split(r.Header.Get("Accept-Encoding"), ",")
|
||||
for i, encoding := range encodings {
|
||||
encodings[i] = strings.TrimSpace(strings.ToLower(encoding))
|
||||
}
|
||||
|
||||
reader, encoding := state.getPoison(mime, encodings)
|
||||
if reader == nil {
|
||||
mime = "application/octet-stream"
|
||||
reader, encoding = state.getPoison(mime, encodings)
|
||||
}
|
||||
|
||||
if reader != nil {
|
||||
defer reader.Close()
|
||||
|
||||
w.Header().Set("Cache-Control", "max-age=0, private, must-revalidate, no-transform")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if encoding != "" {
|
||||
w.Header().Set("Content-Encoding", encoding)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
// trigger chunked encoding
|
||||
flusher.Flush()
|
||||
}
|
||||
if r != nil {
|
||||
_, _ = io.Copy(w, reader)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
}
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
// delete query parameters that were set by go-away
|
||||
for k := range q {
|
||||
if strings.HasPrefix(k, challenge.QueryArgPrefix) {
|
||||
q.Del(k)
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
nextRule:
|
||||
data.Headers(r.Header)
|
||||
|
||||
// delete cookies set by go-away to prevent user tracking that way
|
||||
cookies := r.Cookies()
|
||||
r.Header.Del("Cookie")
|
||||
for _, c := range cookies {
|
||||
if !strings.HasPrefix(c.Name, utils.CookiePrefix) {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serve()
|
||||
return
|
||||
for _, rule := range state.rules {
|
||||
next, err := rule.Evaluate(lg, w, r, func() http.Handler {
|
||||
cleanupRequest(r, true)
|
||||
return backend
|
||||
})
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
panic(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !next {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// default pass
|
||||
_, _ = action.Pass{}.Handle(lg, w, r, func() http.Handler {
|
||||
r.Header.Set("X-Away-Rule", "DEFAULT")
|
||||
r.Header.Set("X-Away-Action", "PASS")
|
||||
|
||||
cleanupRequest(r, false)
|
||||
return backend
|
||||
})
|
||||
}
|
||||
|
||||
func (state *State) setupRoutes() error {
|
||||
|
||||
state.Mux.HandleFunc("/", state.handleRequest)
|
||||
|
||||
if state.Settings.Debug {
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/", pprof.Index)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/profile", pprof.Profile)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/symbol", pprof.Symbol)
|
||||
http.HandleFunc(state.UrlPath+"/debug/pprof/trace", pprof.Trace)
|
||||
if state.Settings().Debug {
|
||||
//TODO: split this to a different listener, metrics listener
|
||||
http.HandleFunc(state.urlPath+"/debug/pprof/", pprof.Index)
|
||||
http.HandleFunc(state.urlPath+"/debug/pprof/profile", pprof.Profile)
|
||||
http.HandleFunc(state.urlPath+"/debug/pprof/symbol", pprof.Symbol)
|
||||
http.HandleFunc(state.urlPath+"/debug/pprof/trace", pprof.Trace)
|
||||
}
|
||||
|
||||
state.Mux.Handle("GET "+state.UrlPath+"/assets/", http.StripPrefix(state.UrlPath, gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
||||
state.Mux.Handle("GET "+state.urlPath+"/assets/", http.StripPrefix(state.UrlPath()+"/assets/", gzipped.FileServer(gzipped.FS(embed.AssetsFs))))
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
if c.ServeStatic != nil {
|
||||
state.Mux.Handle("GET "+c.Path+"/static/", c.ServeStatic)
|
||||
}
|
||||
for _, reg := range state.challenges {
|
||||
|
||||
if c.ServeScript != nil {
|
||||
state.Mux.Handle("GET "+c.ServeScriptPath, c.ServeScript)
|
||||
}
|
||||
|
||||
if c.ServeMakeChallenge != nil {
|
||||
state.Mux.Handle(fmt.Sprintf("POST %s/make-challenge", c.Path), c.ServeMakeChallenge)
|
||||
}
|
||||
|
||||
if c.ServeVerifyChallenge != nil {
|
||||
state.Mux.Handle(fmt.Sprintf("GET %s/verify-challenge", c.Path), c.ServeVerifyChallenge)
|
||||
} else if c.Verify != nil {
|
||||
state.Mux.HandleFunc(fmt.Sprintf("GET %s/verify-challenge", c.Path), func(w http.ResponseWriter, r *http.Request) {
|
||||
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
|
||||
if redirect == "" {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
|
||||
result := r.FormValue("result")
|
||||
|
||||
requestId, err := hex.DecodeString(r.FormValue("requestId"))
|
||||
if err == nil {
|
||||
// override
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(requestId))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
ok, err := c.Verify(key, result, r)
|
||||
state.addTiming(w, "challenge-verify", "Verify client challenge", time.Since(start))
|
||||
|
||||
if err != nil {
|
||||
state.logger(r).Error(fmt.Errorf("challenge error: %w", err).Error(), "challenge", c.Name, "redirect", redirect)
|
||||
return err
|
||||
} else if !ok {
|
||||
state.logger(r).Warn("challenge failed", "challenge", c.Name, "redirect", redirect)
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), redirect)
|
||||
return nil
|
||||
}
|
||||
state.logger(r).Info("challenge passed", "challenge", c.Name, "redirect", redirect)
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+c.Name, token, data.Expires, w)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, redirect)
|
||||
return
|
||||
}
|
||||
})
|
||||
if reg.Handler != nil {
|
||||
state.Mux.Handle(reg.Path+"/", reg.Handler)
|
||||
} else if reg.Verify != nil {
|
||||
// default verify
|
||||
state.Mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,116 +175,12 @@ func (state *State) setupRoutes() error {
|
||||
}
|
||||
|
||||
func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
r, data := challenge.CreateRequestData(r, state)
|
||||
|
||||
var data RequestData
|
||||
// generate random id, todo: is this fast?
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
data.RemoteAddress = getRequestAddress(r, state.Settings.ClientIpHeader)
|
||||
data.Challenges = make(map[challenge.Id]challenge.VerifyResult, len(state.Challenges))
|
||||
data.Expires = time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
|
||||
data.EvaluateChallenges(w, r)
|
||||
|
||||
var ja3n, ja4 string
|
||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||
ja3n = ja3nPtr.String()
|
||||
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||
ja4 = ja4Ptr.String()
|
||||
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
}
|
||||
|
||||
data.ProgramEnv = map[string]any{
|
||||
"host": r.Host,
|
||||
"method": r.Method,
|
||||
"remoteAddress": data.RemoteAddress,
|
||||
"userAgent": r.UserAgent(),
|
||||
"path": r.URL.Path,
|
||||
"fpJA3N": ja3n,
|
||||
"fpJA4": ja4,
|
||||
"query": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.URL.Query() {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
"headers": func() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range r.Header {
|
||||
result[k] = strings.Join(v, ",")
|
||||
}
|
||||
return result
|
||||
}(),
|
||||
}
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
|
||||
|
||||
for _, c := range state.Challenges {
|
||||
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
|
||||
result, err := c.VerifyChallengeToken(state.publicKey, key, r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
// clear invalid cookie
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
}
|
||||
|
||||
// prevent the challenge if not solved
|
||||
if !result.Ok() && c.Program != nil {
|
||||
out, _, err := c.Program.Eval(data.ProgramEnv)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
state.logger(r).Error(err.Error(), "challenge", c.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match!
|
||||
result = challenge.VerifyResultSKIP
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
data.Challenges[c.Id] = result
|
||||
}
|
||||
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(data.Id[:]))
|
||||
if state.Settings.BackendIpHeader != "" {
|
||||
r.Header.Del(state.Settings.ClientIpHeader)
|
||||
r.Header.Set(state.Settings.BackendIpHeader, data.RemoteAddress.String())
|
||||
}
|
||||
// TODO: make this configurable!
|
||||
w.Header().Add("Via", fmt.Sprintf("%s %s", r.Proto, "go-away"))
|
||||
|
||||
// send these to client so we consistently get the headers
|
||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
|
||||
state.Mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
return ctx.Value("_goaway_data").(*RequestData)
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id [16]byte
|
||||
ProgramEnv map[string]any
|
||||
Expires time.Time
|
||||
Challenges map[challenge.Id]challenge.VerifyResult
|
||||
RemoteAddress net.IP
|
||||
}
|
||||
|
||||
func (d *RequestData) HasValidChallenge(id challenge.Id) bool {
|
||||
return d.Challenges[id].Ok()
|
||||
}
|
||||
|
||||
func (d *RequestData) Headers(state *State, headers http.Header) {
|
||||
for id, result := range d.Challenges {
|
||||
if result.Ok() {
|
||||
c, ok := state.Challenges[id]
|
||||
if !ok {
|
||||
panic("challenge not found")
|
||||
}
|
||||
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
158
lib/interface.go
Normal file
158
lib/interface.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Defines challenge.StateInterface
|
||||
|
||||
var _ challenge.StateInterface
|
||||
|
||||
func (state *State) ProgramEnv() *cel.Env {
|
||||
return state.programEnv
|
||||
}
|
||||
|
||||
func (state *State) Client() *http.Client {
|
||||
return state.client
|
||||
}
|
||||
|
||||
func (state *State) PrivateKey() ed25519.PrivateKey {
|
||||
return state.privateKey
|
||||
}
|
||||
|
||||
func (state *State) PublicKey() ed25519.PublicKey {
|
||||
return state.publicKey
|
||||
}
|
||||
|
||||
func (state *State) UrlPath() string {
|
||||
return state.urlPath
|
||||
}
|
||||
|
||||
func (state *State) ChallengeFailed(r *http.Request, reg *challenge.Registration, err error, redirect string, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = state.Logger(r)
|
||||
}
|
||||
logger.Warn("challenge failed", "challenge", reg.Name, "err", err, "redirect", redirect)
|
||||
|
||||
//TODO: metrics
|
||||
}
|
||||
|
||||
func (state *State) ChallengePassed(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = state.Logger(r)
|
||||
}
|
||||
logger.Warn("challenge passed", "challenge", reg.Name, "redirect", redirect)
|
||||
|
||||
//TODO: metrics
|
||||
}
|
||||
|
||||
func (state *State) ChallengeIssued(r *http.Request, reg *challenge.Registration, redirect string, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = state.Logger(r)
|
||||
}
|
||||
logger.Info("challenge issued", "challenge", reg.Name, "redirect", redirect)
|
||||
|
||||
//TODO: metrics
|
||||
}
|
||||
|
||||
func (state *State) Logger(r *http.Request) *slog.Logger {
|
||||
return GetLoggerForRequest(r)
|
||||
}
|
||||
|
||||
func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *challenge.Registration, params map[string]any) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
input := make(map[string]any)
|
||||
input["Id"] = data.Id.String()
|
||||
input["Random"] = utils.CacheBust()
|
||||
if reg != nil {
|
||||
input["Challenge"] = reg.Name
|
||||
input["Path"] = state.UrlPath()
|
||||
}
|
||||
input["Theme"] = state.Settings().ChallengeTemplateTheme
|
||||
|
||||
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, r, http.StatusInternalServerError, err, "")
|
||||
} else {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
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{
|
||||
"Id": data.Id.String(),
|
||||
"Random": utils.CacheBust(),
|
||||
"Error": err.Error(),
|
||||
"Path": state.UrlPath(),
|
||||
"Theme": state.Settings().ChallengeTemplateTheme,
|
||||
"Title": "Oh no! " + http.StatusText(status),
|
||||
"HideSpinner": true,
|
||||
"Challenge": "",
|
||||
"Redirect": redirect,
|
||||
})
|
||||
if err2 != nil {
|
||||
// nested errors!
|
||||
panic(err2)
|
||||
} else {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
func (state *State) GetChallenge(id challenge.Id) (*challenge.Registration, bool) {
|
||||
reg, ok := state.challenges.Get(id)
|
||||
return reg, ok
|
||||
}
|
||||
|
||||
func (state *State) GetChallenges() challenge.Register {
|
||||
return state.challenges
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeByName(name string) (*challenge.Registration, bool) {
|
||||
reg, _, ok := state.challenges.GetByName(name)
|
||||
return reg, ok
|
||||
}
|
||||
func (state *State) Settings() policy.Settings {
|
||||
return state.settings
|
||||
}
|
||||
|
||||
func (state *State) GetBackend(host string) http.Handler {
|
||||
backend, ok := state.Settings().Backends[host]
|
||||
if !ok {
|
||||
// do wildcard match
|
||||
wildcard := "*." + strings.Join(strings.Split(host, ".")[1:], ".")
|
||||
backend, ok = state.Settings().Backends[wildcard]
|
||||
|
||||
if !ok {
|
||||
// return fallback
|
||||
backend = state.Settings().Backends["*"]
|
||||
}
|
||||
}
|
||||
//TODO: dynamic
|
||||
return backend
|
||||
}
|
@@ -1,26 +0,0 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"io"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var poisonEncodings = []string{"br", "zstd", "gzip"}
|
||||
|
||||
func (state *State) getPoison(mime string, encodings []string) (r io.ReadCloser, encoding string) {
|
||||
for _, encoding = range poisonEncodings {
|
||||
if !slices.Contains(encodings, encoding) {
|
||||
continue
|
||||
}
|
||||
|
||||
p := path.Join("poison", strings.ReplaceAll(mime, "/", "_")+"."+encoding+".poison")
|
||||
f, err := embed.PoisonFs.Open(p)
|
||||
if err == nil {
|
||||
return f, encoding
|
||||
}
|
||||
}
|
||||
return nil, ""
|
||||
}
|
@@ -1,15 +1,15 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Challenge struct {
|
||||
Conditions []string `yaml:"conditions"`
|
||||
Mode string `yaml:"mode"`
|
||||
Asset *string `yaml:"asset,omitempty"`
|
||||
Url *string `yaml:"url,omitempty"`
|
||||
Runtime string `yaml:"runtime"`
|
||||
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
Runtime struct {
|
||||
Mode string `yaml:"mode,omitempty"`
|
||||
Asset string `yaml:"asset,omitempty"`
|
||||
Probability float64 `yaml:"probability,omitempty"`
|
||||
} `yaml:"runtime"`
|
||||
Duration time.Duration `yaml:"duration"`
|
||||
|
||||
Parameters ast.Node `yaml:"parameters,omitempty"`
|
||||
}
|
||||
|
18
lib/policy/options.go
Normal file
18
lib/policy/options.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Backends map[string]http.Handler
|
||||
PrivateKeySeed []byte
|
||||
Debug bool
|
||||
PackageName string
|
||||
ChallengeTemplate string
|
||||
ChallengeTemplateTheme string
|
||||
ClientIpHeader string
|
||||
BackendIpHeader string
|
||||
|
||||
ChallengeResponseCode int
|
||||
}
|
@@ -1,22 +1,40 @@
|
||||
package policy
|
||||
|
||||
import "github.com/goccy/go-yaml/ast"
|
||||
|
||||
type RuleAction string
|
||||
|
||||
const (
|
||||
RuleActionPASS RuleAction = "PASS"
|
||||
RuleActionDENY RuleAction = "DENY"
|
||||
RuleActionBLOCK RuleAction = "BLOCK"
|
||||
// RuleActionNONE Does nothing. Useful for parent rules when children want to be specified
|
||||
RuleActionNONE RuleAction = "NONE"
|
||||
// RuleActionPASS Passes the connection immediately
|
||||
RuleActionPASS RuleAction = "PASS"
|
||||
// RuleActionDENY Denies the connection with a fancy page
|
||||
RuleActionDENY RuleAction = "DENY"
|
||||
// RuleActionBLOCK Denies the connection with a response code
|
||||
RuleActionBLOCK RuleAction = "BLOCK"
|
||||
// RuleActionCODE Returns a specified HTTP code
|
||||
RuleActionCODE RuleAction = "CODE"
|
||||
|
||||
// RuleActionDROP Drops the connection without sending a reply
|
||||
RuleActionDROP RuleAction = "DROP"
|
||||
|
||||
// RuleActionCHALLENGE Issues a challenge that when passed, passes the connection
|
||||
RuleActionCHALLENGE RuleAction = "CHALLENGE"
|
||||
RuleActionCHECK RuleAction = "CHECK"
|
||||
RuleActionPOISON RuleAction = "POISON"
|
||||
// RuleActionCHECK Issues a challenge that when passed, continues checking rules
|
||||
RuleActionCHECK RuleAction = "CHECK"
|
||||
|
||||
// RuleActionPROXY Proxies request to a backend, with optional path replacements
|
||||
RuleActionPROXY RuleAction = "PROXY"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
Name string `yaml:"name"`
|
||||
Host *string `yaml:"host"`
|
||||
Conditions []string `yaml:"conditions"`
|
||||
|
||||
Action string `yaml:"action"`
|
||||
|
||||
Challenges []string `yaml:"challenges"`
|
||||
Settings ast.Node `yaml:"settings"`
|
||||
|
||||
Children []Rule `yaml:"children"`
|
||||
}
|
||||
|
141
lib/rule.go
Normal file
141
lib/rule.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/action"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RuleState struct {
|
||||
Name string
|
||||
Hash string
|
||||
|
||||
Condition cel.Program
|
||||
|
||||
Action policy.RuleAction
|
||||
Handler action.Handler
|
||||
|
||||
Children []RuleState
|
||||
}
|
||||
|
||||
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
|
||||
fp := sha256.Sum256(state.PrivateKey())
|
||||
hasher := sha256.New()
|
||||
if parent != nil {
|
||||
hasher.Write([]byte(parent.Name))
|
||||
hasher.Write([]byte{0})
|
||||
r.Name = fmt.Sprintf("%s/%s", parent.Name, r.Name)
|
||||
}
|
||||
hasher.Write([]byte(r.Name))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(fp[:])
|
||||
sum := hasher.Sum(nil)
|
||||
|
||||
rule := RuleState{
|
||||
Name: r.Name,
|
||||
Hash: hex.EncodeToString(sum[:10]),
|
||||
Action: policy.RuleAction(strings.ToUpper(r.Action)),
|
||||
}
|
||||
|
||||
newHandler, ok := action.Register[rule.Action]
|
||||
if !ok {
|
||||
return RuleState{}, fmt.Errorf("unknown action %s", r.Action)
|
||||
}
|
||||
|
||||
actionHandler, err := newHandler(state, rule.Name, rule.Hash, r.Settings)
|
||||
if err != nil {
|
||||
return RuleState{}, err
|
||||
}
|
||||
rule.Handler = actionHandler
|
||||
|
||||
if len(r.Conditions) > 0 {
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range r.Conditions {
|
||||
cond = replacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("error compiling conditions: %w", err)
|
||||
}
|
||||
|
||||
program, err := condition.Program(state.ProgramEnv(), ast)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("error compiling program: %w", err)
|
||||
}
|
||||
rule.Condition = program
|
||||
}
|
||||
|
||||
if len(r.Children) > 0 {
|
||||
for _, child := range r.Children {
|
||||
childRule, err := NewRuleState(state, child, replacer, &rule)
|
||||
if err != nil {
|
||||
return RuleState{}, fmt.Errorf("child %s: %w", child.Name, err)
|
||||
}
|
||||
rule.Children = append(rule.Children, childRule)
|
||||
}
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (rule RuleState) Evaluate(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() http.Handler) (next bool, err error) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
var out ref.Val
|
||||
|
||||
lg := logger.With("rule", rule.Name, "rule_hash", rule.Hash, "action", string(rule.Action))
|
||||
if rule.Condition != nil {
|
||||
out, _, err = rule.Condition.Eval(data)
|
||||
} else {
|
||||
// default true
|
||||
out = types.Bool(true)
|
||||
}
|
||||
if err != nil {
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: evaluating administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) == types.True {
|
||||
next, err = rule.Handler.Handle(lg, w, r, func() http.Handler {
|
||||
r.Header.Set("X-Away-Rule", rule.Name)
|
||||
r.Header.Set("X-Away-Hash", rule.Hash)
|
||||
r.Header.Set("X-Away-Action", string(rule.Action))
|
||||
|
||||
return done()
|
||||
})
|
||||
if err != nil {
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
}
|
||||
|
||||
if !next {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
for _, child := range rule.Children {
|
||||
next, err = child.Evaluate(logger, w, r, done)
|
||||
if err != nil {
|
||||
lg.Error(err.Error())
|
||||
return false, fmt.Errorf("error: executing administrative rule %s/%s: %w", data.Id.String(), rule.Hash, err)
|
||||
}
|
||||
|
||||
if !next {
|
||||
return next, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
729
lib/state.go
729
lib/state.go
@@ -1,157 +1,76 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge/wasm"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"github.com/yl2chen/cidranger"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Client *http.Client
|
||||
Settings StateSettings
|
||||
UrlPath string
|
||||
Mux *http.ServeMux
|
||||
client *http.Client
|
||||
urlPath string
|
||||
|
||||
Networks map[string]cidranger.Ranger
|
||||
|
||||
Wasm *wasm.Runner
|
||||
|
||||
Challenges map[challenge.Id]challenge.Challenge
|
||||
|
||||
RulesEnv *cel.Env
|
||||
|
||||
Rules []RuleState
|
||||
programEnv *cel.Env
|
||||
|
||||
publicKey ed25519.PublicKey
|
||||
privateKey ed25519.PrivateKey
|
||||
|
||||
Poison map[string][]byte
|
||||
settings policy.Settings
|
||||
|
||||
ChallengeSolve sync.Map
|
||||
networks map[string]cidranger.Ranger
|
||||
|
||||
DecayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse]
|
||||
challenges challenge.Register
|
||||
|
||||
rules []RuleState
|
||||
|
||||
close chan struct{}
|
||||
|
||||
Mux *http.ServeMux
|
||||
}
|
||||
|
||||
func (state *State) AwaitChallenge(key []byte, ctx context.Context) challenge.VerifyResult {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var result atomic.Int64
|
||||
|
||||
state.ChallengeSolve.Store(string(key), ChallengeCallback(func(receivedResult challenge.VerifyResult) {
|
||||
result.Store(int64(receivedResult))
|
||||
cancel()
|
||||
}))
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return challenge.VerifyResult(result.Load())
|
||||
}
|
||||
|
||||
func (state *State) SolveChallenge(key []byte, result challenge.VerifyResult) {
|
||||
if f, ok := state.ChallengeSolve.LoadAndDelete(string(key)); ok && f != nil {
|
||||
if cb, ok := f.(ChallengeCallback); ok {
|
||||
cb(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChallengeCallback func(result challenge.VerifyResult)
|
||||
|
||||
type RuleState struct {
|
||||
Name string
|
||||
Hash string
|
||||
|
||||
Host *string
|
||||
|
||||
Program cel.Program
|
||||
Action policy.RuleAction
|
||||
Challenges []challenge.Id
|
||||
}
|
||||
|
||||
type StateSettings struct {
|
||||
Backends map[string]http.Handler
|
||||
PrivateKeySeed []byte
|
||||
Debug bool
|
||||
PackageName string
|
||||
ChallengeTemplate string
|
||||
ChallengeTemplateTheme string
|
||||
ClientIpHeader string
|
||||
BackendIpHeader string
|
||||
DNSBL *utils.DNSBL
|
||||
}
|
||||
|
||||
func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, err error) {
|
||||
func NewState(p policy.Policy, settings policy.Settings) (handler http.Handler, err error) {
|
||||
state := new(State)
|
||||
state.close = make(chan struct{})
|
||||
state.Settings = settings
|
||||
state.Client = &http.Client{
|
||||
state.settings = settings
|
||||
state.client = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
state.UrlPath = "/.well-known/." + state.Settings.PackageName
|
||||
|
||||
if state.Settings.DNSBL != nil {
|
||||
state.DecayMap = utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
||||
}
|
||||
state.urlPath = "/.well-known/." + state.Settings().PackageName
|
||||
|
||||
// set a reasonable configuration for default http proxy if there is none
|
||||
for _, backend := range state.Settings.Backends {
|
||||
for _, backend := range state.Settings().Backends {
|
||||
if proxy, ok := backend.(*httputil.ReverseProxy); ok {
|
||||
if proxy.ErrorHandler == nil {
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
state.logger(r).Error(err.Error())
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadGateway, err, "")
|
||||
state.Logger(r).Error(err.Error())
|
||||
state.ErrorPage(w, r, http.StatusBadGateway, err, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(state.Settings.PrivateKeySeed) > 0 {
|
||||
if len(state.Settings.PrivateKeySeed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings.PrivateKeySeed))
|
||||
if len(state.Settings().PrivateKeySeed) > 0 {
|
||||
if len(state.Settings().PrivateKeySeed) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("invalid private key seed length: %d", len(state.Settings().PrivateKeySeed))
|
||||
}
|
||||
|
||||
state.privateKey = ed25519.NewKeyFromSeed(state.Settings.PrivateKeySeed)
|
||||
state.privateKey = ed25519.NewKeyFromSeed(state.Settings().PrivateKeySeed)
|
||||
state.publicKey = state.privateKey.Public().(ed25519.PublicKey)
|
||||
|
||||
clear(state.Settings.PrivateKeySeed)
|
||||
clear(state.settings.PrivateKeySeed)
|
||||
|
||||
} else {
|
||||
state.publicKey, state.privateKey, err = ed25519.GenerateKey(rand.Reader)
|
||||
@@ -160,34 +79,32 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
}
|
||||
}
|
||||
|
||||
privateKeyFingerprint := sha256.Sum256(state.privateKey)
|
||||
|
||||
if state.Settings.ChallengeTemplate == "" {
|
||||
state.Settings.ChallengeTemplate = "anubis"
|
||||
if state.Settings().ChallengeTemplate == "" {
|
||||
state.settings.ChallengeTemplate = "anubis"
|
||||
}
|
||||
|
||||
if templates["challenge-"+state.Settings.ChallengeTemplate+".gohtml"] == nil {
|
||||
if templates["challenge-"+state.Settings().ChallengeTemplate+".gohtml"] == nil {
|
||||
|
||||
if data, err := os.ReadFile(state.Settings.ChallengeTemplate); err == nil && len(data) > 0 {
|
||||
name := path.Base(state.Settings.ChallengeTemplate)
|
||||
if data, err := os.ReadFile(state.Settings().ChallengeTemplate); err == nil && len(data) > 0 {
|
||||
name := path.Base(state.Settings().ChallengeTemplate)
|
||||
err := initTemplate(name, string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading template %s: %w", settings.ChallengeTemplate, err)
|
||||
}
|
||||
state.Settings.ChallengeTemplate = name
|
||||
state.settings.ChallengeTemplate = name
|
||||
}
|
||||
|
||||
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 {
|
||||
ranger := cidranger.NewPCTrieRanger()
|
||||
for _, e := range network {
|
||||
if e.Url != nil {
|
||||
slog.Debug("loading network url list", "network", k, "url", *e.Url)
|
||||
}
|
||||
prefixes, err := e.FetchPrefixes(state.Client)
|
||||
prefixes, err := e.FetchPrefixes(state.client)
|
||||
if err != nil {
|
||||
slog.Error("error fetching network url list", "network", k, "url", *e.Url)
|
||||
continue
|
||||
@@ -202,11 +119,9 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
|
||||
slog.Warn("loaded network prefixes", "network", k, "count", ranger.Len())
|
||||
|
||||
state.Networks[k] = ranger
|
||||
state.networks[k] = ranger
|
||||
}
|
||||
|
||||
state.Wasm = wasm.NewRunner(true)
|
||||
|
||||
err = state.initConditions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -214,7 +129,7 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
|
||||
var replacements []string
|
||||
for k, entries := range p.Conditions {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
|
||||
ast, err := condition.FromStrings(state.programEnv, condition.OperatorOr, entries...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
|
||||
}
|
||||
@@ -229,563 +144,26 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
}
|
||||
conditionReplacer := strings.NewReplacer(replacements...)
|
||||
|
||||
state.Challenges = make(map[challenge.Id]challenge.Challenge)
|
||||
|
||||
idCounter := challenge.Id(1)
|
||||
state.challenges = make(challenge.Register)
|
||||
|
||||
//TODO: move this to self-contained challenge files
|
||||
for challengeName, p := range p.Challenges {
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range p.Conditions {
|
||||
cond = conditionReplacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
for challengeName, pol := range p.Challenges {
|
||||
_, _, err := state.challenges.Create(state, challengeName, pol, conditionReplacer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: %w", challengeName, err)
|
||||
}
|
||||
|
||||
var program cel.Program
|
||||
if len(conditions) > 0 {
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: error compiling conditions: %v", challengeName, err)
|
||||
}
|
||||
program, err = state.RulesEnv.Program(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("challenge %s: error compiling program: %v", challengeName, err)
|
||||
}
|
||||
}
|
||||
|
||||
c := challenge.Challenge{
|
||||
Id: idCounter,
|
||||
Program: program,
|
||||
Name: challengeName,
|
||||
Path: fmt.Sprintf("%s/challenge/%s", state.UrlPath, challengeName),
|
||||
VerifyProbability: p.Runtime.Probability,
|
||||
}
|
||||
idCounter++
|
||||
|
||||
if c.VerifyProbability <= 0 {
|
||||
//10% default
|
||||
c.VerifyProbability = 0.1
|
||||
} else if c.VerifyProbability > 1.0 {
|
||||
c.VerifyProbability = 1.0
|
||||
}
|
||||
|
||||
assetPath := c.Path + "/static/"
|
||||
subFs, err := fs.Sub(embed.ChallengeFs, fmt.Sprintf("challenge/%s/static", challengeName))
|
||||
if err == nil {
|
||||
c.ServeStatic = http.StripPrefix(
|
||||
assetPath,
|
||||
gzipped.FileServer(gzipped.FS(subFs)),
|
||||
)
|
||||
}
|
||||
|
||||
switch p.Mode {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge mode: %s", p.Mode)
|
||||
case "http":
|
||||
if p.Url == nil {
|
||||
return nil, fmt.Errorf("challenge %s: missing url", challengeName)
|
||||
}
|
||||
method := p.Parameters["http-method"]
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
httpCode, _ := strconv.Atoi(p.Parameters["http-code"])
|
||||
if httpCode == 0 {
|
||||
httpCode = http.StatusOK
|
||||
}
|
||||
|
||||
expectedCookie := p.Parameters["http-cookie"]
|
||||
|
||||
c.Verify = func(key []byte, result string, r *http.Request) (bool, error) {
|
||||
var cookieValue string
|
||||
if expectedCookie != "" {
|
||||
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return false, nil
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
}
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key)
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(state.publicKey)
|
||||
|
||||
if subtle.ConstantTimeCompare(sum.Sum(nil), []byte(result)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
if result := data.Challenges[c.Id]; result.Ok() {
|
||||
return challenge.ResultPass
|
||||
}
|
||||
|
||||
var cookieValue string
|
||||
if expectedCookie != "" {
|
||||
if cookie, err := r.Cookie(expectedCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return challenge.ResultContinue
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(method, *p.Url, nil)
|
||||
if err != nil {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
|
||||
request.Header = r.Header
|
||||
response, err := state.Client.Do(request)
|
||||
if err != nil {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
if response.StatusCode != httpCode {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
// continue other challenges!
|
||||
|
||||
//TODO: negatively cache failure
|
||||
|
||||
return challenge.ResultContinue
|
||||
} else {
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key)
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(state.publicKey)
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
// we passed it!
|
||||
return challenge.ResultPass
|
||||
}
|
||||
}
|
||||
|
||||
case "cookie":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
if chall := r.URL.Query().Get("__goaway_challenge"); chall == challengeName {
|
||||
state.logger(r).Warn("challenge failed", "challenge", c.Name)
|
||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", c.Name), "")
|
||||
return challenge.ResultStop
|
||||
}
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, nil, expiry)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
|
||||
}
|
||||
|
||||
// self redirect!
|
||||
uri, err := url.ParseRequestURI(r.URL.String())
|
||||
values := uri.Query()
|
||||
values.Set("__goaway_challenge", challengeName)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "meta-refresh":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("redirect", r.URL.String())
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", map[string]any{
|
||||
"Meta": map[string]string{
|
||||
"refresh": "0; url=" + redirectUri.String(),
|
||||
},
|
||||
})
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "header-refresh":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("redirect", r.URL.String())
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "0; url="+redirectUri.String())
|
||||
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", nil)
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "preload-link":
|
||||
deadline, _ := time.ParseDuration(p.Parameters["preload-early-hint-deadline"])
|
||||
if deadline == 0 {
|
||||
deadline = time.Second * 3
|
||||
}
|
||||
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
// this only works on HTTP/2 and HTTP/3
|
||||
|
||||
if r.ProtoMajor < 2 {
|
||||
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
|
||||
if _, ok := w.(http.Pusher); !ok {
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Scheme = getRequestScheme(r)
|
||||
redirectUri.Host = r.Host
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", redirectUri.String()))
|
||||
defer func() {
|
||||
// remove old header so it won't show on response!
|
||||
w.Header().Del("Link")
|
||||
}()
|
||||
w.WriteHeader(http.StatusEarlyHints)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), deadline)
|
||||
defer cancel()
|
||||
if result := state.AwaitChallenge(key, ctx); result.Ok() {
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
|
||||
// this should serve!
|
||||
return challenge.ResultPass
|
||||
}
|
||||
|
||||
data.Challenges[c.Id] = challenge.VerifyResultFAIL
|
||||
// we failed, continue
|
||||
return challenge.ResultContinue
|
||||
}
|
||||
case "resource-load":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
redirectUri := new(url.URL)
|
||||
redirectUri.Path = c.Path + "/verify-challenge"
|
||||
|
||||
values := make(url.Values)
|
||||
values.Set("result", hex.EncodeToString(key))
|
||||
values.Set("requestId", r.Header.Get("X-Away-Id"))
|
||||
|
||||
redirectUri.RawQuery = values.Encode()
|
||||
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, "", map[string]any{
|
||||
"Tags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", redirectUri.String())),
|
||||
},
|
||||
})
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
case "js":
|
||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||
_ = state.challengePage(w, r.Header.Get("X-Away-Id"), http.StatusTeapot, challengeName, nil)
|
||||
|
||||
return challenge.ResultStop
|
||||
}
|
||||
c.ServeScriptPath = c.Path + "/challenge.mjs"
|
||||
c.ServeScript = 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("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err := templates["challenge.mjs"].Execute(w, map[string]any{
|
||||
"Path": c.Path,
|
||||
"Parameters": string(params),
|
||||
"Random": cacheBust,
|
||||
"Challenge": challengeName,
|
||||
"ChallengeScript": func() string {
|
||||
if p.Asset != nil {
|
||||
return assetPath + *p.Asset
|
||||
} else if p.Url != nil {
|
||||
return *p.Url
|
||||
} else {
|
||||
panic("not implemented")
|
||||
}
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// how to runtime
|
||||
switch p.Runtime.Mode {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge runtime mode: %s", p.Runtime.Mode)
|
||||
case "":
|
||||
case "http":
|
||||
case "key":
|
||||
mimeType := p.Parameters["key-mime"]
|
||||
if mimeType == "" {
|
||||
mimeType = "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
httpCode, _ := strconv.Atoi(p.Parameters["key-code"])
|
||||
if httpCode == 0 {
|
||||
httpCode = http.StatusTemporaryRedirect
|
||||
}
|
||||
|
||||
var content []byte
|
||||
if data, ok := p.Parameters["key-content"]; ok {
|
||||
content = []byte(data)
|
||||
}
|
||||
|
||||
c.Verify = func(key []byte, result string, r *http.Request) (bool, error) {
|
||||
resultBytes, err := hex.DecodeString(result)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(resultBytes, key) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
c.ServeVerifyChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
redirect, err := utils.EnsureNoOpenRedirect(r.FormValue("redirect"))
|
||||
if err != nil {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
key := state.GetChallengeKeyForRequest(challengeName, data.Expires, r)
|
||||
result := r.FormValue("result")
|
||||
|
||||
requestId, err := hex.DecodeString(r.FormValue("requestId"))
|
||||
if err == nil {
|
||||
r.Header.Set("X-Away-Id", hex.EncodeToString(requestId))
|
||||
}
|
||||
|
||||
if ok, err := c.Verify(key, result, r); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
data.Challenges[c.Id] = challenge.VerifyResultFAIL
|
||||
state.SolveChallenge(key, challenge.VerifyResultFAIL)
|
||||
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
|
||||
|
||||
// catch happy eyeballs IPv4 -> IPv6 migration, re-direct to try again
|
||||
if resultKey, err := ChallengeKeyFromString(result); err == nil && resultKey.Get(ChallengeKeyFlagIsIPv4) > 0 && key.Get(ChallengeKeyFlagIsIPv4) == 0 {
|
||||
|
||||
} else {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
state.logger(r).Warn("challenge passed", "challenge", challengeName, "redirect", redirect)
|
||||
|
||||
token, err := c.IssueChallengeToken(state.privateKey, key, []byte(result), data.Expires)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+challengeName, token, data.Expires, w)
|
||||
}
|
||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||
state.SolveChallenge(key, challenge.VerifyResultPASS)
|
||||
}
|
||||
|
||||
switch httpCode {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
if redirect == "" {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusBadRequest, errors.New("no redirect found"), "")
|
||||
return nil
|
||||
}
|
||||
http.Redirect(w, r, redirect, httpCode)
|
||||
default:
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.WriteHeader(httpCode)
|
||||
if content != nil {
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, redirect)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
case "wasm":
|
||||
wasmData, err := embed.ChallengeFs.ReadFile(fmt.Sprintf("challenge/%s/runtime/%s", challengeName, p.Runtime.Asset))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("c %s: could not load runtime: %w", challengeName, err)
|
||||
}
|
||||
err = state.Wasm.Compile(challengeName, wasmData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("c %s: compiling runtime: %w", challengeName, err)
|
||||
}
|
||||
|
||||
c.ServeMakeChallenge = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := state.Wasm.Instantiate(challengeName, func(ctx context.Context, mod api.Module) (err error) {
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
|
||||
in := _interface.MakeChallengeInput{
|
||||
Key: state.GetChallengeKeyForRequest(challengeName, data.Expires, r),
|
||||
Parameters: p.Parameters,
|
||||
Headers: inline.MIMEHeader(r.Header),
|
||||
}
|
||||
in.Data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := wasm.MakeChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set output headers
|
||||
for k, v := range out.Headers {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
c.Verify = func(key []byte, result string, r *http.Request) (ok bool, err error) {
|
||||
err = state.Wasm.Instantiate(challengeName, func(ctx context.Context, mod api.Module) (err error) {
|
||||
in := _interface.VerifyChallengeInput{
|
||||
Key: key,
|
||||
Parameters: p.Parameters,
|
||||
Result: []byte(result),
|
||||
}
|
||||
|
||||
out, err := wasm.VerifyChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == _interface.VerifyChallengeOutputError {
|
||||
return errors.New("error checking challenge")
|
||||
}
|
||||
ok = out == _interface.VerifyChallengeOutputOK
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
state.Challenges[c.Id] = c
|
||||
}
|
||||
|
||||
for _, rule := range p.Rules {
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(rule.Name))
|
||||
hasher.Write([]byte{0})
|
||||
if rule.Host != nil {
|
||||
hasher.Write([]byte(*rule.Host))
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(privateKeyFingerprint[:])
|
||||
sum := hasher.Sum(nil)
|
||||
for _, r := range p.Rules {
|
||||
|
||||
challenges := make([]challenge.Id, 0, len(rule.Challenges))
|
||||
|
||||
for _, challengeName := range rule.Challenges {
|
||||
c, ok := state.GetChallengeByName(challengeName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("challenge %s not found", challengeName)
|
||||
}
|
||||
challenges = append(challenges, c.Id)
|
||||
}
|
||||
|
||||
r := RuleState{
|
||||
Name: rule.Name,
|
||||
Hash: hex.EncodeToString(sum[:8]),
|
||||
Host: rule.Host,
|
||||
Action: policy.RuleAction(strings.ToUpper(rule.Action)),
|
||||
Challenges: challenges,
|
||||
}
|
||||
|
||||
if (r.Action == policy.RuleActionCHALLENGE || r.Action == policy.RuleActionCHECK) && len(r.Challenges) == 0 {
|
||||
return nil, fmt.Errorf("no challenges found in rule %s", rule.Name)
|
||||
}
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range rule.Conditions {
|
||||
cond = conditionReplacer.Replace(cond)
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, conditions...)
|
||||
rule, err := NewRuleState(state, r, conditionReplacer, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules %s: error compiling conditions: %v", rule.Name, err)
|
||||
return nil, fmt.Errorf("rule %s: %w", r.Name, err)
|
||||
}
|
||||
program, err := state.RulesEnv.Program(ast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules %s: error compiling program: %v", rule.Name, err)
|
||||
}
|
||||
r.Program = program
|
||||
|
||||
slog.Warn("loaded rule", "rule", r.Name, "hash", r.Hash, "action", rule.Action)
|
||||
slog.Warn("loaded rule", "rule", rule.Name, "hash", rule.Hash, "action", rule.Action, "children", len(rule.Children))
|
||||
|
||||
state.Rules = append(state.Rules, r)
|
||||
state.rules = append(state.rules, rule)
|
||||
}
|
||||
|
||||
state.Mux = http.NewServeMux()
|
||||
@@ -794,28 +172,5 @@ func NewState(p policy.Policy, settings StateSettings) (handler http.Handler, er
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if state.DecayMap != nil {
|
||||
go func() {
|
||||
ticker := time.NewTicker(17 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
state.DecayMap.Decay()
|
||||
case <-state.close:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (state *State) GetChallengeByName(name string) (challenge.Challenge, bool) {
|
||||
for _, c := range state.Challenges {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return challenge.Challenge{}, false
|
||||
}
|
||||
|
@@ -1,27 +1,41 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var CookiePrefix = ".go-away-"
|
||||
|
||||
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter) {
|
||||
// getValidHost Gets a valid host for an http.Cookie Domain field
|
||||
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
|
||||
func getValidHost(host string) string {
|
||||
ipStr, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
return ipStr
|
||||
}
|
||||
|
||||
func SetCookie(name, value string, expiry time.Time, w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Expires: expiry,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
Domain: getValidHost(r.Host),
|
||||
})
|
||||
}
|
||||
func ClearCookie(name string, w http.ResponseWriter) {
|
||||
|
||||
func ClearCookie(name string, w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: getValidHost(r.Host),
|
||||
})
|
||||
}
|
||||
|
@@ -16,31 +16,27 @@ import (
|
||||
)
|
||||
|
||||
func applyTLSFingerprinter(server *http.Server) {
|
||||
if server.TLSConfig == nil {
|
||||
return
|
||||
}
|
||||
server.TLSConfig = server.TLSConfig.Clone()
|
||||
|
||||
getCertificate := server.TLSConfig.GetCertificate
|
||||
if getCertificate == nil {
|
||||
server.TLSConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||
fpPtr.ja3n.Store(&ja3n)
|
||||
fpPtr.ja4.Store(&ja4)
|
||||
}
|
||||
getConfigForClient := server.TLSConfig.GetConfigForClient
|
||||
|
||||
if getConfigForClient == nil {
|
||||
getConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
server.TLSConfig.GetCertificate = func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||
fpPtr.ja3n.Store(&ja3n)
|
||||
fpPtr.ja4.Store(&ja4)
|
||||
}
|
||||
}
|
||||
|
||||
return getCertificate(clientHello)
|
||||
server.TLSConfig.GetConfigForClient = func(clientHello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
ja3n, ja4 := buildTLSFingerprint(clientHello)
|
||||
ptr := clientHello.Context().Value(tlsFingerprintKey{})
|
||||
if fpPtr, ok := ptr.(*TLSFingerprint); ok && ptr != nil && fpPtr != nil {
|
||||
fpPtr.ja3n.Store(&ja3n)
|
||||
fpPtr.ja4.Store(&ja4)
|
||||
}
|
||||
return getConfigForClient(clientHello)
|
||||
}
|
||||
server.ConnContext = func(ctx context.Context, c net.Conn) context.Context {
|
||||
return context.WithValue(ctx, tlsFingerprintKey{}, &TLSFingerprint{})
|
||||
|
@@ -2,7 +2,9 @@ package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -79,3 +81,46 @@ func MakeReverseProxy(target string) (*httputil.ReverseProxy, error) {
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func GetRequestScheme(r *http.Request) string {
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
|
||||
return proto
|
||||
}
|
||||
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
|
||||
return "http"
|
||||
}
|
||||
|
||||
func GetRequestAddress(r *http.Request, clientHeader string) net.IP {
|
||||
var ipStr string
|
||||
if clientHeader != "" {
|
||||
ipStr = r.Header.Get(clientHeader)
|
||||
}
|
||||
if ipStr != "" {
|
||||
// handle X-Forwarded-For
|
||||
ipStr = strings.Split(ipStr, ",")[0]
|
||||
}
|
||||
|
||||
// fallback
|
||||
if ipStr == "" {
|
||||
ipStr, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||
}
|
||||
ipStr = strings.Trim(ipStr, "[]")
|
||||
return net.ParseIP(ipStr)
|
||||
}
|
||||
|
||||
func CacheBust() string {
|
||||
return cacheBust
|
||||
}
|
||||
|
||||
var cacheBust string
|
||||
|
||||
func init() {
|
||||
|
||||
buf := make([]byte, 16)
|
||||
_, _ = rand.Read(buf)
|
||||
cacheBust = base64.RawURLEncoding.EncodeToString(buf)
|
||||
}
|
||||
|
Reference in New Issue
Block a user