New challenge for HTTP/2 clients, preload-link

This commit is contained in:
WeebDataHoarder
2025-04-08 02:17:03 +02:00
parent d2513d2bab
commit 2ce9709667
5 changed files with 110 additions and 8 deletions

View File

@@ -84,6 +84,8 @@ func (v *MultiVar) Set(value string) error {
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),
}

View File

@@ -112,7 +112,17 @@ 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:
mode: "preload-link"
runtime:
# verifies that result = key
mode: "key"
probability: 0.1
# Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic)
self-header-refresh:
mode: "header-refresh"
runtime:
@@ -120,7 +130,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 +196,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 +310,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 +411,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 +445,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

@@ -36,6 +36,8 @@ import (
"path"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -59,8 +61,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
@@ -273,6 +303,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
@@ -282,7 +315,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)
@@ -350,6 +382,50 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
return challenge.ResultStop
}
case "preload-link":
deadline := time.Second * 5
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
if _, ok := w.(http.Pusher); !ok {
return challenge.ResultContinue
}
}
data := RequestDataFromContext(r.Context())
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()
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=preload; as=fetch; crossorigin=1", redirectUri.String()))
defer func() {
// remove old header header!
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)
@@ -467,6 +543,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
}
@@ -481,6 +560,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)