Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,41 @@ browsers that really know how to dance!
## Supported CAPTCHAs
- None (Simple JS execute)
- POW
- [Turnstile](https://developers.cloudflare.com/turnstile/)
- [hCaptcha](https://www.hcaptcha.com/)
- [reCAPTCHA v2](https://developers.google.com/recaptcha)

## Planned support
- Simple Captcha (Including Sound)
- [hCaptcha](https://www.hcaptcha.com/)
- [reCatpcha](https://developers.google.com/recaptcha?hl=de)
- [Turnstile](https://developers.cloudflare.com/turnstile/)

## Captcha challenge types

The `turnstile`, `hcaptcha` and `recaptcha` (v2 checkbox) level types render the provider widget
on the challenge page and exchange its response token for a Berghain cookie after verifying it
against the provider:

```yaml
default:
levels:
- duration: 12h
type: turnstile # or hcaptcha / recaptcha
sitekey: <your sitekey>
secret: <your secret>
```

Things to know when enabling a captcha level:

- The Berghain agent verifies tokens against the provider's `siteverify` endpoint, so it needs
outbound HTTPS access. Verification fails closed: if the provider is unreachable, the challenge
fails and the visitor can retry. `verify_url` overrides the endpoint, e.g. for `recaptcha.net`.
- Challenge verification does a network round-trip, so the SPOE challenge group needs a larger
`timeout processing` than the validate path. The example config runs the two groups as separate
agents (`berghain` at 100ms, `berghain_challenge` at 6s) for this reason.
- The token is only accepted when the provider-reported hostname matches the request identity
(subdomains included). Provider *test keys* report a fixed hostname, so tests can set
`skip_hostname_check: true`; production setups should never need it.
- Visitors' browsers load the widget script from the provider's domain. If you serve the challenge
page with a Content-Security-Policy, allow the provider in `script-src` and `frame-src`.

## Example setup with HAProxy
To start berghain locally you can follow these easy steps:
Expand Down
29 changes: 29 additions & 0 deletions berghain.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/sha256"
"hash"
"log/slog"
"net/http"
"sync"
"time"
)
Expand All @@ -13,12 +14,31 @@ type LevelConfig struct {
Countdown int
Duration time.Duration
Type ValidationType

// Captcha configuration, required for the turnstile, hcaptcha and
// recaptcha validation types.
CaptchaSitekey string
CaptchaSecret string
// CaptchaVerifyURL overrides the provider siteverify endpoint,
// e.g. for regional endpoints or tests.
CaptchaVerifyURL string
// CaptchaSkipHostnameCheck disables binding the provider-reported
// hostname to the request identity. Provider test keys report a
// fixed hostname, so tests need this; production setups do not.
CaptchaSkipHostnameCheck bool

captchaBodyOnce sync.Once
captchaBody []byte
}

type Berghain struct {
Levels []*LevelConfig
TrustedDomains []string

// HTTPClient is used for captcha siteverify requests.
// Defaults to a client with a 5 second timeout.
HTTPClient *http.Client

secret []byte
hmac sync.Pool
}
Expand All @@ -36,6 +56,15 @@ func NewBerghain(secret []byte) *Berghain {
}
}

var defaultHTTPClient = &http.Client{Timeout: 5 * time.Second}

func (b *Berghain) httpClient() *http.Client {
if b.HTTPClient != nil {
return b.HTTPClient
}
return defaultHTTPClient
}

func (b *Berghain) acquireHMAC() hash.Hash {
return b.hmac.Get().(hash.Hash)
}
Expand Down
33 changes: 33 additions & 0 deletions cmd/spop/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ type LevelConfig struct {
Countdown *int `yaml:"countdown"`
Duration time.Duration `yaml:"duration"`
Type string `yaml:"type"`

// Captcha settings, required for the turnstile, hcaptcha and
// recaptcha types.
Sitekey string `yaml:"sitekey"`
Secret string `yaml:"secret"`
// VerifyURL overrides the provider siteverify endpoint,
// e.g. for regional endpoints or tests.
VerifyURL string `yaml:"verify_url"`
// SkipHostnameCheck disables binding the provider-reported hostname
// to the request identity. Provider test keys report a fixed
// hostname, so tests need this; production setups do not.
SkipHostnameCheck bool `yaml:"skip_hostname_check"`
}

func (c LevelConfig) AsLevelConfig() *berghain.LevelConfig {
Expand All @@ -77,10 +89,31 @@ func (c LevelConfig) AsLevelConfig() *berghain.LevelConfig {
lc.Type = berghain.ValidationTypeNone
case "pow":
lc.Type = berghain.ValidationTypePOW
case "turnstile":
lc.Type = berghain.ValidationTypeTurnstile
case "hcaptcha":
lc.Type = berghain.ValidationTypeHCaptcha
case "recaptcha":
lc.Type = berghain.ValidationTypeReCaptcha
default:
Fatal("unknown validation type", "validator", c.Type)
}

switch lc.Type {
case berghain.ValidationTypeTurnstile, berghain.ValidationTypeHCaptcha, berghain.ValidationTypeReCaptcha:
if c.Sitekey == "" || c.Secret == "" {
Fatal("captcha types require a sitekey and a secret", "validator", c.Type)
}
lc.CaptchaSitekey = c.Sitekey
lc.CaptchaSecret = c.Secret
lc.CaptchaVerifyURL = c.VerifyURL
lc.CaptchaSkipHostnameCheck = c.SkipHostnameCheck
default:
if c.Sitekey != "" || c.Secret != "" || c.VerifyURL != "" || c.SkipHostnameCheck {
Fatal("sitekey, secret, verify_url and skip_hostname_check are only valid for captcha types", "validator", c.Type)
}
}

return &lc
}

Expand Down
6 changes: 6 additions & 0 deletions cmd/spop/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ frontend:
- duration: 10s
type: pow
countdown: 0
# captcha levels (turnstile, hcaptcha, recaptcha) verify the widget
# token against the provider, so the agent needs outbound HTTPS access
- duration: 12h
type: turnstile
sitekey: 1x00000000000000000000AA # dummy sitekey, always passes
secret: 1x0000000000000000000000000000000AA # dummy secret, always passes
17 changes: 16 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ <h3>Your IP is bound, not stored</h3>
<h3>No third-party CDN</h3>
<p>The verification screen and any proof-of-work run in your own browser and are served from
the same site you are visiting. There are no external trackers, fonts or scripts loaded
from someone else&#39;s servers &mdash; and this help page follows the same rule.</p>
from someone else&#39;s servers &mdash; and this help page follows the same rule. The one
exception: if the operator enables a captcha level, the widget script is loaded from that
captcha provider (Cloudflare Turnstile, hCaptcha or Google reCAPTCHA) and the provider&#39;s
own privacy policy applies to it.</p>
</div>
</div>
<div class="note">
Expand Down Expand Up @@ -312,6 +315,18 @@ <h3>Enforced by Berghain</h3>
only hands out the puzzle and checks the answer.</p>
</div>
</details>

<details class="accordion">
<summary>Captcha challenge (Turnstile, hCaptcha, reCAPTCHA)</summary>
<div class="body">
<p>A level can require solving a captcha widget from Cloudflare Turnstile, hCaptcha or
Google reCAPTCHA. The widget appears on the verification screen; once it is satisfied,
its response is checked with the provider and you receive the same clearance cookie as
with any other level. The widget script is loaded from the provider&#39;s servers, so a
content blocker that blocks the provider will also block the verification &mdash; the
screen will tell you when that happens.</p>
</div>
</details>
</div>

<div class="accordion-group">
Expand Down
22 changes: 18 additions & 4 deletions examples/haproxy/berghain.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,32 @@ spoe-agent berghain
timeout processing 100ms
use-backend berghain_spop
log global
groups validate challenge
groups validate

spoe-message validate
# The order is relevant, as haproxy is sending them in-order
args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) cookie=req.cook(berghain)

spoe-group validate
messages validate

# The challenge group runs as its own agent: captcha levels verify the
# widget token against the provider over HTTPS, so challenge processing
# needs a far larger timeout than the per-request validate path.
[berghain_challenge]
spoe-agent berghain_challenge
option var-prefix berghain
option set-on-error error
timeout hello 100ms
timeout idle 10m
timeout processing 6s
use-backend berghain_spop
log global
groups challenge

spoe-message challenge
# The order is relevant, as haproxy is sending them in-order
args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) method=method body=req.body

spoe-group validate
messages validate

spoe-group challenge
messages challenge
4 changes: 2 additions & 2 deletions examples/haproxy/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ backend app_backend

backend berghain_http
mode http
filter spoe engine berghain config examples/haproxy/berghain.cfg
filter spoe engine berghain_challenge config examples/haproxy/berghain.cfg

acl is_challenge_path path /cdn-cgi/challenge-platform/challenge

http-request send-spoe-group berghain challenge if is_challenge_path
http-request send-spoe-group berghain_challenge challenge if is_challenge_path
http-request return status 501 if { var(txn.berghain.error) -m found }

acl has_token var(txn.berghain.token) -m found
Expand Down
26 changes: 23 additions & 3 deletions test/e2e/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import (
)

const (
defaultBaseURL = "http://localhost:18080"
backendBody = "Berghain E2E backend reached"
defaultBaseURL = "http://localhost:18080"
defaultTurnstileURL = "http://localhost:18081"
backendBody = "Berghain E2E backend reached"
)

func baseURL() string {
Expand All @@ -28,6 +29,13 @@ func baseURL() string {
return defaultBaseURL
}

func turnstileURL() string {
if value := os.Getenv("BERGHAIN_E2E_TURNSTILE_URL"); value != "" {
return strings.TrimRight(value, "/")
}
return defaultTurnstileURL
}

func requireChallengePage(t *testing.T, url string) {
t.Helper()

Expand All @@ -51,7 +59,19 @@ func requireChallengePage(t *testing.T, url string) {
}

func TestBrowserSolvesChallenge(t *testing.T) {
url := baseURL()
solveChallengeInBrowser(t, baseURL())
}

// TestBrowserSolvesTurnstileChallenge drives the full captcha flow with
// Cloudflare's always-passing dummy keys, so it needs egress to the real
// Turnstile script and siteverify endpoints.
func TestBrowserSolvesTurnstileChallenge(t *testing.T) {
solveChallengeInBrowser(t, turnstileURL())
}

func solveChallengeInBrowser(t *testing.T, url string) {
t.Helper()

requireChallengePage(t, url)

options := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...)
Expand Down
22 changes: 20 additions & 2 deletions test/e2e/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,32 @@ frontend e2e

default_backend app_backend

frontend e2e_turnstile
bind 127.0.0.1:18081

http-request set-var(req.berghain.level) int(1)

filter spoe engine berghain config examples/haproxy/berghain.cfg

acl berghain_path path /cdn-cgi/challenge-platform/challenge
http-request send-spoe-group berghain validate if !berghain_path
http-request return status 501 if { var(txn.berghain.error) -m found }

acl berghain_valid var(txn.berghain.valid) -m bool
http-request return status 403 content-type "text/html" file "web/dist/default/index.html" if !berghain_valid !berghain_path
http-request wait-for-body time 5s if berghain_path METH_POST
use_backend berghain_http if berghain_path

default_backend app_backend

backend app_backend
http-request return status 200 content-type "text/plain" string "Berghain E2E backend reached"

backend berghain_http
filter spoe engine berghain config examples/haproxy/berghain.cfg
filter spoe engine berghain_challenge config examples/haproxy/berghain.cfg

acl is_challenge_path path /cdn-cgi/challenge-platform/challenge
http-request send-spoe-group berghain challenge if is_challenge_path
http-request send-spoe-group berghain_challenge challenge if is_challenge_path
http-request return status 501 if { var(txn.berghain.error) -m found }

acl has_token var(txn.berghain.token) -m found
Expand Down
25 changes: 14 additions & 11 deletions test/e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,23 @@ berghain_pid=$!
haproxy -db -f test/e2e/haproxy.cfg >"$run_dir/haproxy.log" 2>&1 &
haproxy_pid=$!

ready=""
for _ in $(seq 1 30); do
status="$(curl --max-time 1 --silent --output /dev/null --write-out '%{http_code}' http://localhost:18080/ || true)"
if [[ $status == 403 ]]; then
ready=1
break
for port in 18080 18081; do
ready=""
for _ in $(seq 1 30); do
status="$(curl --max-time 1 --silent --output /dev/null --write-out '%{http_code}' "http://localhost:$port/" || true)"
if [[ $status == 403 ]]; then
ready=1
break
fi
sleep 1
done
if [[ -z "$ready" ]]; then
echo "E2E stack did not serve the challenge page on port $port" >&2
exit 1
fi
sleep 1
done
if [[ -z "$ready" ]]; then
echo "E2E stack did not serve the challenge page" >&2
exit 1
fi

export BERGHAIN_E2E_BASE_URL=http://localhost:18080
export BERGHAIN_E2E_TURNSTILE_URL=http://localhost:18081
cd test/e2e
go test -count=1 -tags=e2e -v .
11 changes: 11 additions & 0 deletions test/e2e/spop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@ default:
- duration: 2m
type: pow
countdown: 1

frontend:
e2e_turnstile:
levels:
- duration: 2m
type: turnstile
countdown: 1
sitekey: 1x00000000000000000000AA # dummy sitekey, always passes
secret: 1x0000000000000000000000000000000AA # dummy secret, always passes
# the dummy keys report hostname example.com instead of localhost
skip_hostname_check: true
Loading
Loading