12 Commits

Author SHA1 Message Date
WeebDataHoarder
f80d6ebd15 Set X-Away-Id on response 2025-04-08 13:13:19 +02:00
WeebDataHoarder
10dc2a0177 Remove Sec-Ch-Ua and Sec-Ch-Ua-Platform from challenge key 2025-04-08 12:49:55 +02:00
WeebDataHoarder
baf9df9f0a Allow conditions on challenges, and early hint deadline 2025-04-08 11:40:16 +02:00
WeebDataHoarder
b0ab78ef65 Disable passthrough mode by default 2025-04-08 02:52:23 +02:00
WeebDataHoarder
f272a5ae72 Load challenge as style 2025-04-08 02:38:53 +02:00
WeebDataHoarder
2ce9709667 New challenge for HTTP/2 clients, preload-link 2025-04-08 02:17:03 +02:00
WeebDataHoarder
d2513d2bab Add h2c support 2025-04-08 01:52:27 +02:00
WeebDataHoarder
04e102cb74 Fix arm64 builds 2025-04-08 01:29:02 +02:00
WeebDataHoarder
7be88ca6af Remove deprecated squash flag 2025-04-07 23:50:50 +02:00
WeebDataHoarder
b3f471bb30 Pass http challenge if we passed it previously on the request 2025-04-07 23:44:29 +02:00
WeebDataHoarder
1144424eff Trigger docker publish on latest master push as well 2025-04-07 22:38:44 +02:00
WeebDataHoarder
903b4e26bd Fix Docker image prefix 2025-04-07 22:23:34 +02:00
13 changed files with 409 additions and 158 deletions

View File

@@ -51,7 +51,7 @@ local Build(go, alpine, os, arch) = {
]
};
local Publish(go, alpine, os, arch, platforms) = {
local Publish(go, alpine, os, arch, trigger, platforms, extra) = {
kind: "pipeline",
type: "docker",
name: "publish-" + go + "-alpine" + alpine,
@@ -59,19 +59,18 @@ local Publish(go, alpine, os, arch, platforms) = {
os: os,
arch: arch,
},
trigger: {
event: ["promote"],
target: ["production"],
},
trigger: trigger,
steps: [
{
name: "docker",
image: "plugins/buildx",
privileged: true,
environment: {
DOCKER_BUILDKIT: "1"
},
settings: {
registry: "git.gammaspectra.live",
repo: "git.gammaspectra.live/git/go-away",
squash: true,
compress: true,
platform: platforms,
builder_driver: "docker-container",
@@ -79,15 +78,14 @@ local Publish(go, alpine, os, arch, platforms) = {
from_builder: "golang:" + go +"-alpine" + alpine,
from: "alpine:" + alpine,
},
auto_tag: true,
auto_tag_suffix: "-alpine" + alpine,
auto_tag_suffix: "alpine" + alpine,
username: {
from_secret: "git_username",
},
password: {
from_secret: "git_password",
},
}
} + extra,
},
]
};
@@ -100,6 +98,9 @@ local Publish(go, alpine, os, arch, platforms) = {
Build("1.24", "3.21", "linux", "amd64"),
Build("1.24", "3.21", "linux", "arm64"),
Publish("1.24", "3.21", "linux", "amd64", ["linux/amd64", "linux/arm64"]),
Publish("1.22", "3.20", "linux", "amd64", ["linux/amd64", "linux/arm64"]),
# latest
Publish("1.24", "3.21", "linux", "amd64", {event: ["push"], branch: ["master"], }, ["linux/amd64", "linux/arm64"], {tags: ["latest"],}) + {name: "publish-latest"},
Publish("1.24", "3.21", "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, ["linux/amd64", "linux/arm64"], {auto_tag: true,}),
# legacy
Publish("1.22", "3.20", "linux", "amd64", {event: ["promote", "tag"], target: ["production"], }, ["linux/amd64", "linux/arm64"], {auto_tag: true,}),
]

View File

@@ -160,17 +160,55 @@ steps:
type: docker
---
kind: pipeline
name: publish-1.24-alpine3.21
name: publish-latest
platform:
arch: amd64
os: linux
steps:
- image: plugins/buildx
- environment:
DOCKER_BUILDKIT: "1"
image: plugins/buildx
name: docker
privileged: true
settings:
auto_tag: true
auto_tag_suffix: -alpine3.21
auto_tag_suffix: alpine3.21
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
builder_driver: docker-container
compress: true
password:
from_secret: git_password
platform:
- linux/amd64
- linux/arm64
registry: git.gammaspectra.live
repo: git.gammaspectra.live/git/go-away
tags:
- latest
username:
from_secret: git_username
trigger:
branch:
- master
event:
- push
type: docker
---
kind: pipeline
name: publish-1.24-alpine3.21
platform:
arch: amd64
os: linux
steps:
- environment:
DOCKER_BUILDKIT: "1"
image: plugins/buildx
name: docker
privileged: true
settings:
auto_tag: true
auto_tag_suffix: alpine3.21
build_args:
from: alpine:3.21
from_builder: golang:1.24-alpine3.21
@@ -183,12 +221,12 @@ steps:
- linux/arm64
registry: git.gammaspectra.live
repo: git.gammaspectra.live/git/go-away
squash: true
username:
from_secret: git_username
trigger:
event:
- promote
- tag
target:
- production
type: docker
@@ -199,12 +237,14 @@ platform:
arch: amd64
os: linux
steps:
- image: plugins/buildx
- environment:
DOCKER_BUILDKIT: "1"
image: plugins/buildx
name: docker
privileged: true
settings:
auto_tag: true
auto_tag_suffix: -alpine3.20
auto_tag_suffix: alpine3.20
build_args:
from: alpine:3.20
from_builder: golang:1.22-alpine3.20
@@ -217,17 +257,17 @@ steps:
- linux/arm64
registry: git.gammaspectra.live
repo: git.gammaspectra.live/git/go-away
squash: true
username:
from_secret: git_username
trigger:
event:
- promote
- tag
target:
- production
type: docker
---
kind: signature
hmac: df5f717113694708251e53b4a30070d44ce0fc1dbc0975d5b90fab130f5d1f2a
hmac: 3cbd114d368c7bd348105921d85c703db1c1bc46de79f00daabbca23ffac6050
...

View File

@@ -1,13 +1,13 @@
ARG from_builder=golang:1.24-alpine3.21
ARG from=alpine:3.21
ARG TARGETPLATFORM
ARG TARGETARCH
ARG TARGETOS
ARG BUILDPLATFORM
FROM --platform=$BUILDPLATFORM ${from_builder} AS build
ARG TARGETARCH
ARG TARGETOS
RUN apk update && apk add --no-cache \
bash \
git \

View File

@@ -11,6 +11,8 @@ import (
"git.gammaspectra.live/git/go-away/lib"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"gopkg.in/yaml.v3"
"log"
"log/slog"
@@ -79,6 +81,18 @@ func (v *MultiVar) Set(value string) error {
return nil
}
func newServer(handler http.Handler) *http.Server {
h2s := &http2.Server{}
// TODO: use Go 1.24 Server.Protocols to add H2C
// https://pkg.go.dev/net/http#Server.Protocols
h1s := &http.Server{
Handler: h2c.NewHandler(handler, h2s),
}
return h1s
}
func main() {
bind := flag.String("bind", ":8080", "network address to bind HTTP to")
bindNetwork := flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
@@ -86,7 +100,7 @@ func main() {
slogLevel := flag.String("slog-level", "WARN", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
debugMode := flag.Bool("debug", false, "debug mode with logs and server timings")
passThrough := flag.Bool("passthrough", true, "passthrough mode sends all requests to matching backends until state is loaded")
passThrough := flag.Bool("passthrough", false, "passthrough mode sends all requests to matching backends until state is loaded")
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.)")
@@ -196,17 +210,15 @@ func main() {
go func() {
defer wg.Done()
server := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backend, ok := createdBackends[r.Host]
if !ok {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
server := newServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backend, ok := createdBackends[r.Host]
if !ok {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
}
backend.ServeHTTP(w, r)
}),
}
backend.ServeHTTP(w, r)
}))
listener, listenUrl := setupListener(*bindNetwork, *bind, *socketMode)
slog.Warn(
@@ -228,10 +240,10 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Close()
if err := server.Shutdown(ctx); err != nil {
log.Fatal(err)
}
_ = server.Close()
}()
}
@@ -259,9 +271,7 @@ func main() {
"url", listenUrl,
)
server := http.Server{
Handler: state,
}
server := newServer(state)
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)

View File

@@ -112,7 +112,23 @@ challenges:
self-cookie:
mode: "cookie"
# Challenges with a redirect via header (non-JS, requires HTTP parsing and logic)
# 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
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:
@@ -120,7 +136,7 @@ challenges:
mode: "key"
probability: 0.1
# Challenges with a redirect via meta (non-JS, requires HTML parsing and logic)
# Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic)
self-meta-refresh:
mode: "meta-refresh"
runtime:
@@ -186,6 +202,7 @@ conditions:
# Golang proxy and initial fetch
- 'userAgent.startsWith("GoModuleMirror/")'
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
- '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"'
is-git-path:
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
@@ -299,8 +316,12 @@ rules:
- name: suspicious-crawlers/1
conditions: ['($is-suspicious-crawler)']
action: check
challenges: [self-header-refresh]
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]
@@ -396,7 +417,7 @@ rules:
# check a sequence of challenges
- name: heavy-operations/0
action: check
challenges: [self-header-refresh, js-pow-sha256, http-cookie-check]
challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check]
conditions: ['($is-heavy-resource)']
- name: heavy-operations/1
action: check
@@ -430,6 +451,6 @@ rules:
- name: standard-browser
action: challenge
challenges: [http-cookie-check, self-meta-refresh, self-resource-load, js-pow-sha256]
challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256]
conditions:
- '($is-generic-browser)'

6
go.mod
View File

@@ -13,6 +13,7 @@ require (
github.com/klauspost/compress v1.18.0
github.com/tetratelabs/wazero v1.9.0
github.com/yl2chen/cidranger v1.0.2
golang.org/x/net v0.26.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -24,6 +25,7 @@ require (
github.com/stoewer/go-strcase v1.3.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/protobuf v1.36.6 // indirect
@@ -37,7 +39,7 @@ replace golang.org/x/exp v0.0.0 => ./utils/exp
// Pin latest versions to support Go 1.22 to prevent a package update from changing them
// TODO: remove this when Go 1.22+ is supported by other higher users
replace (
golang.org/x/crypto => golang.org/x/crypto v0.33.0
google.golang.org/genproto/googleapis/api => google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7
google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7
golang.org/x/crypto => golang.org/x/crypto v0.33.0
)
)

2
go.sum
View File

@@ -46,6 +46,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=

View File

@@ -20,6 +20,18 @@ type ChallengeInformation struct {
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 != "" {
@@ -36,6 +48,7 @@ func getRequestAddress(r *http.Request, clientHeader string) net.IP {
// drop port
ipStr = strings.Join(parts[:len(parts)-1], ":")
}
ipStr = strings.Trim(ipStr, "[]")
return net.ParseIP(ipStr)
}
@@ -52,8 +65,9 @@ func (state *State) GetChallengeKeyForRequest(challengeName string, until time.T
"Accept-Language",
// General browser information
"User-Agent",
"Sec-Ch-Ua",
"Sec-Ch-Ua-Platform",
// TODO: not sent in preload
//"Sec-Ch-Ua",
//"Sec-Ch-Ua-Platform",
} {
hasher.Write([]byte(r.Header.Get(k)))
hasher.Write([]byte{0})

View File

@@ -7,6 +7,7 @@ import (
"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"
@@ -26,9 +27,10 @@ const (
type Id int
type Challenge struct {
Id Id
Name string
Path string
Id Id
Program cel.Program
Name string
Path string
Verify func(key []byte, result string, r *http.Request) (bool, error)
VerifyProbability float64
@@ -86,6 +88,7 @@ type VerifyResult int
const (
VerifyResultNONE = VerifyResult(iota)
VerifyResultFAIL
VerifyResultSKIP
// VerifyResultPASS Client just passed this challenge
VerifyResultPASS
@@ -95,7 +98,7 @@ const (
)
func (r VerifyResult) Ok() bool {
return r > VerifyResultFAIL
return r >= VerifyResultPASS
}
func (r VerifyResult) String() string {
@@ -104,6 +107,8 @@ func (r VerifyResult) String() string {
return "NONE"
case VerifyResultFAIL:
return "FAIL"
case VerifyResultSKIP:
return "SKIP"
case VerifyResultPASS:
return "PASS"
case VerifyResultOK:

69
lib/conditions.go Normal file
View File

@@ -0,0 +1,69 @@
package lib
import (
"fmt"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"net"
)
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)),
// http.Header
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
//TODO: dynamic type?
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)
}
}),
),
),
)
if err != nil {
return err
}
return nil
}

View File

@@ -159,29 +159,6 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
start := time.Now()
//TODO better matcher! combo ast?
env := map[string]any{
"host": host,
"method": r.Method,
"remoteAddress": getRequestAddress(r, state.Settings.ClientIpHeader),
"userAgent": r.UserAgent(),
"path": r.URL.Path,
"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
}(),
}
state.addTiming(w, "rule-env", "Setup the rule environment", time.Since(start))
var (
@@ -211,7 +188,7 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
continue
}
start = time.Now()
out, _, err := rule.Program.Eval(env)
out, _, err := rule.Program.Eval(data.ProgramEnv)
ruleEvalDuration += time.Since(start)
if err != nil {
@@ -230,7 +207,6 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
serve()
return
case policy.RuleActionCHALLENGE, policy.RuleActionCHECK:
for _, challengeId := range rule.Challenges {
if result := data.Challenges[challengeId]; !result.Ok() {
continue
@@ -249,6 +225,11 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
// 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)
@@ -264,7 +245,10 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
}
state.logger(r).Warn("challenge passed", "rule", rule.Name, "rule_hash", rule.Hash, "challenge", c.Name)
data.Challenges[c.Id] = challenge.VerifyResultOK
// 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)
@@ -425,6 +409,27 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_, _ = rand.Read(data.Id[:])
data.Challenges = make(map[challenge.Id]challenge.VerifyResult, len(state.Challenges))
data.Expires = time.Now().UTC().Add(DefaultValidity).Round(DefaultValidity)
data.ProgramEnv = map[string]any{
"host": r.Host,
"method": r.Method,
"remoteAddress": getRequestAddress(r, state.Settings.ClientIpHeader),
"userAgent": r.UserAgent(),
"path": r.URL.Path,
"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
}(),
}
for _, c := range state.Challenges {
key := state.GetChallengeKeyForRequest(c.Name, data.Expires, r)
@@ -433,10 +438,30 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 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[:]))
w.Header().Set("X-Away-Id", hex.EncodeToString(data.Id[:]))
// 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")
r = r.WithContext(context.WithValue(r.Context(), "_goaway_data", &data))
@@ -449,6 +474,7 @@ func RequestDataFromContext(ctx context.Context) *RequestData {
type RequestData struct {
Id [16]byte
ProgramEnv map[string]any
Expires time.Time
Challenges map[challenge.Id]challenge.VerifyResult
}

View File

@@ -1,9 +1,10 @@
package policy
type Challenge struct {
Mode string `yaml:"mode"`
Asset *string `yaml:"asset,omitempty"`
Url *string `yaml:"url,omitempty"`
Conditions []string `yaml:"conditions"`
Mode string `yaml:"mode"`
Asset *string `yaml:"asset,omitempty"`
Url *string `yaml:"url,omitempty"`
Parameters map[string]string `json:"parameters,omitempty"`
Runtime struct {

View File

@@ -20,15 +20,12 @@ import (
"git.gammaspectra.live/git/go-away/utils"
"git.gammaspectra.live/git/go-away/utils/inline"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/tetratelabs/wazero/api"
"github.com/yl2chen/cidranger"
"html/template"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
@@ -36,6 +33,8 @@ import (
"path"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -59,8 +58,36 @@ type State struct {
privateKey ed25519.PrivateKey
Poison map[string][]byte
ChallengeSolve sync.Map
}
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
@@ -167,13 +194,56 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
state.Wasm = wasm.NewRunner(true)
err = state.initConditions()
if err != nil {
return nil, err
}
var replacements []string
for k, entries := range p.Conditions {
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
if err != nil {
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
}
cond, err := cel.AstToString(ast)
if err != nil {
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
}
replacements = append(replacements, fmt.Sprintf("($%s)", k))
replacements = append(replacements, "("+cond+")")
}
conditionReplacer := strings.NewReplacer(replacements...)
state.Challenges = make(map[challenge.Id]challenge.Challenge)
idCounter := challenge.Id(1)
for challengeName, p := range p.Challenges {
// allow nesting
var conditions []string
for _, cond := range p.Conditions {
cond = conditionReplacer.Replace(cond)
conditions = append(conditions, cond)
}
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,
@@ -240,6 +310,13 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
}
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 {
@@ -266,6 +343,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
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
@@ -275,7 +355,6 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
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)
@@ -283,6 +362,8 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
utils.SetCookie(utils.CookiePrefix+challengeName, token, expiry, w)
}
data.Challenges[c.Id] = challenge.VerifyResultPASS
// we passed it!
return challenge.ResultPass
}
@@ -341,6 +422,54 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
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)
@@ -458,6 +587,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
} else if !ok {
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
state.SolveChallenge(key, challenge.VerifyResultFAIL)
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
return nil
}
@@ -472,6 +604,8 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
}
data.Challenges[c.Id] = challenge.VerifyResultPASS
state.SolveChallenge(key, challenge.VerifyResultPASS)
switch httpCode {
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
http.Redirect(w, r, redirect, httpCode)
@@ -566,80 +700,6 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
state.Challenges[c.Id] = c
}
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)),
// http.Header
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
//TODO: dynamic type?
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)
}
}),
),
),
)
if err != nil {
return nil, err
}
var replacements []string
for k, entries := range p.Conditions {
ast, err := condition.FromStrings(state.RulesEnv, condition.OperatorOr, entries...)
if err != nil {
return nil, fmt.Errorf("conditions %s: error compiling conditions: %v", k, err)
}
cond, err := cel.AstToString(ast)
if err != nil {
return nil, fmt.Errorf("conditions %s: error printing condition: %v", k, err)
}
replacements = append(replacements, fmt.Sprintf("($%s)", k))
replacements = append(replacements, "("+cond+")")
}
conditionReplacer := strings.NewReplacer(replacements...)
for _, rule := range p.Rules {
hasher := sha256.New()
hasher.Write([]byte(rule.Name))