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
1 change: 1 addition & 0 deletions berghain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func TestBerghain(t *testing.T) {
Level: 1,
}
req.Method = "GET"
req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")

resp := AcquireValidatorResponse()
err := bh.LevelConfig(req.Identifier.Level).Type.RunValidator(bh, req, resp)
Expand Down
25 changes: 25 additions & 0 deletions cmd/spop/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ func readExpectedKVEntry(ctx context.Context, m *encoding.Message, k *encoding.K
return nil
}

func addOptionalSessionToContext(ctx context.Context, m *encoding.Message, k *encoding.KVEntry) (context.Context, []byte) {
if !m.KV.Next(k) {
if err := m.KV.Error(); err != nil {
slog.ErrorContext(ctx, "error while reading optional session argument", "error", err)
}
return ctx, nil
}

if !k.NameEquals("session") {
slog.WarnContext(ctx, "ignoring unexpected optional SPOP argument", "have", k.NameBytes())
return ctx, nil
}

if id := k.ValueBytes(); berghain.ValidSupportID(id) {
return context.WithValue(ctx, "session", string(id)), id
}

slog.DebugContext(ctx, "ignoring invalid session id")
return ctx, nil
}

const hostBufferLength = 256

var hostBufPool = sync.Pool{
Expand Down Expand Up @@ -281,11 +302,15 @@ func (f *frontend) HandleSPOEChallenge(ctx context.Context, w *encoding.ActionWr
return
}
req.Body = k.ValueBytes()
ctx, req.SupportID = addOptionalSessionToContext(ctx, m, k)

resp := berghain.AcquireValidatorResponse()
defer berghain.ReleaseValidatorResponse(resp)

err := f.bh.LevelConfig(ri.Level).Type.RunValidator(f.bh, req, resp)
if berghain.ValidSupportID(req.SupportID) {
ctx = context.WithValue(ctx, "session", string(req.SupportID))
}
if err != nil {
slog.ErrorContext(ctx, "validator failed", "error", err)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/spop/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func (lh *logHandler) Handle(ctx context.Context, r slog.Record) error {
for _, k := range []string{
"handler",
"host",
"session",
"src",
"validator",
} {
Expand Down
13 changes: 7 additions & 6 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ <h3>5. Try another browser</h3>
</div>
<div class="card">
<h3>6. Still stuck?</h3>
<p>If the verification still repeats on an up-to-date, mainstream browser,
<a href="#report">report an issue</a> with your browser and operating system so an
operator can investigate.</p>
<p>Note the support ID shown on the verification screen (it looks like
<code>bh@&lt;uuid&gt;</code>) and <a href="#report">report an issue</a> so an operator can
correlate your report with its challenge logs.</p>
</div>
</div>
</section>
Expand Down Expand Up @@ -406,9 +406,10 @@ <h2><span class="rope" aria-hidden="true"></span>Report an issue</h2>
operator can help.</p>
<div class="card">
<h3>What to include</h3>
<p>Tell us your browser and version, your operating system, the address you were trying to
reach and what happened. A screenshot of the verification screen helps too. The more detail
you provide, the faster an operator can reproduce and fix the problem.</p>
<p>Include the per-page support ID shown on the verification screen, your browser and version,
your operating system, the address you were trying to reach and what happened. The ID is
randomly generated by your browser and is used only to correlate the report with challenge
logs; it is not derived from your IP address.</p>
</div>
<div class="btn-row" style="margin-top:16px">
<a class="btn primary" href="https://github.com/DropMorePackets/berghain/issues">Report an issue on GitHub</a>
Expand Down
2 changes: 1 addition & 1 deletion examples/haproxy/berghain.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ spoe-agent berghain_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
args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) method=method body=req.body session=var(txn.berghain.session)

spoe-group challenge
messages challenge
3 changes: 3 additions & 0 deletions examples/haproxy/haproxy-ua-policy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ frontend test

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

# HAProxy issues the initial support ID; continuation requests carry it in their body.
http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET

# These paths deliberately bypass the UA policy. Use exact paths or a
# slash-terminated prefix so an unrelated path cannot match accidentally.
acl public_endpoint path -i /feed /feed.xml /rss /rss.xml /atom.xml
Expand Down
6 changes: 5 additions & 1 deletion examples/haproxy/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ frontend test
bind *:8080
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %Th/%Ti/%TR/%Tq/%Tw/%Tc/%Tr/%Tt\ %ST\ %B\ %CC\ %CS\ %tsc\ %ac/%fc/%bc/%sc/%rc\ %sq/%bq\ %hr\ %hs\ %{+Q}r\ %ID spoa-error:\ %[var(txn.berghain.error)]"

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

# HAProxy issues the initial support ID; continuation requests carry it in their body.
http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET

http-request track-sc1 src table st_src

filter spoe engine berghain config examples/haproxy/berghain.cfg
Expand All @@ -29,7 +34,6 @@ frontend test

acl berghain_active var(req.berghain.level) -m found

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

Expand Down
26 changes: 26 additions & 0 deletions support_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package berghain

import "bytes"

const supportIDLength = len("bh@00000000-0000-4000-8000-000000000000")

func ValidSupportID(id []byte) bool {
if len(id) != supportIDLength || !bytes.Equal(id[:3], []byte("bh@")) {
return false
}

for i, c := range id[3:] {
switch i {
case 8, 13, 18, 23:
if c != '-' {
return false
}
default:
if !('0' <= c && c <= '9') && !('a' <= c && c <= 'f') && !('A' <= c && c <= 'F') {
return false
}
}
}

return true
}
26 changes: 26 additions & 0 deletions support_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package berghain

import "testing"

func TestValidSupportID(t *testing.T) {
tests := []struct {
name string
id string
want bool
}{
{name: "lowercase", id: "bh@123e4567-e89b-12d3-a456-426614174000", want: true},
{name: "uppercase", id: "bh@123E4567-E89B-12D3-A456-426614174000", want: true},
{name: "wrong prefix", id: "foo@123e4567-e89b-12d3-a456-426614174000"},
{name: "too short", id: "bh@123e4567"},
{name: "invalid hex", id: "bh@123e4567-e89b-12d3-a456-42661417400z"},
{name: "invalid separators", id: "bh@123e4567xe89bx12d3xa456x426614174000"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidSupportID([]byte(tt.id)); got != tt.want {
t.Fatalf("ValidSupportID(%q) = %v, want %v", tt.id, got, tt.want)
}
})
}
}
8 changes: 8 additions & 0 deletions test/e2e/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ frontend e2e
filter spoe engine berghain config examples/haproxy/berghain.cfg

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

# HAProxy issues the initial support ID; continuation requests carry it in their body.
http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET

http-request send-spoe-group berghain validate if !berghain_path
http-request return status 501 if { var(txn.berghain.error) -m found }

Expand All @@ -37,6 +41,10 @@ frontend e2e_turnstile
filter spoe engine berghain config examples/haproxy/berghain.cfg

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

# HAProxy issues the initial support ID; continuation requests carry it in their body.
http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET

http-request send-spoe-group berghain validate if !berghain_path
http-request return status 501 if { var(txn.berghain.error) -m found }

Expand Down
7 changes: 6 additions & 1 deletion validator_none.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ var validatorNoneResponse = mustJSONEncodeString(struct {
})

func validatorNone(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
if !ValidSupportID(req.SupportID) {
return ErrInvalidLength
}

lc := b.LevelConfig(req.Identifier.Level)

copy(resp.Body.WriteBytes(), validatorNoneResponse)
resp.Body.AdvanceW(len(`{"c":`))
// the following conversion is faster than sprintf but also way uglier, I am sorry.
// 48 is the ASCII code for '0', adding lc.Countdown will give us the single correct digit.
copy(resp.Body.WriteNBytes(1), []byte{byte(48 + lc.Countdown)})
resp.Body.AdvanceW(len(`,"t":0}`))
resp.Body.AdvanceW(len(`,"t":0`))
appendSupportID(resp.Body, req.SupportID)

return req.Identifier.ToCookie(b, resp.Token)
}
15 changes: 12 additions & 3 deletions validator_none_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package berghain

import (
"bytes"
"encoding/json"
"net/http"
"net/netip"
"testing"
Expand All @@ -25,14 +25,23 @@ func Test_validatorNone(t *testing.T) {
Level: 1,
}
req.Method = http.MethodGet
req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")

err := validatorNone(bh, req, resp)
if err != nil {
t.Errorf("validator failed: %v", err)
}

if !bytes.Equal(resp.Body.ReadBytes(), []byte(validatorNoneResponse)) {
t.Errorf("invalid response: %s != %s", validatorNoneResponse, resp.Body.ReadBytes())
var challenge struct {
Countdown int `json:"c"`
Type int `json:"t"`
SupportID string `json:"i"`
}
if err := json.Unmarshal(resp.Body.ReadBytes(), &challenge); err != nil {
t.Fatalf("decode response: %v", err)
}
if challenge.Type != 0 || challenge.SupportID != string(req.SupportID) {
t.Errorf("invalid response: %+v", challenge)
}

err = bh.IsValidCookie(*req.Identifier, resp.Token.ReadBytes())
Expand Down
57 changes: 41 additions & 16 deletions validator_pow.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ type powValidator struct {
}

const (
validatorPOWRandom = "0000000000000000"
validatorPOWTimestamp = "0000000000000000"
validatorPOWRandom = validatorPOWTimestamp + "bh@00000000-0000-4000-8000-000000000000"
validatorPOWHash = "0000000000000000000000000000000000000000000000000000000000000000"
validatorPOWMinSolutionLength = len(validatorPOWRandom + "-" + validatorPOWHash + "-0")
validatorPOWMaxSolutionLength = validatorPOWMinSolutionLength + 19
)

var validatorPOWChallengeTemplate = mustJSONEncodeString(struct {
Expand All @@ -45,7 +47,7 @@ var validatorPOWChallengeTemplate = mustJSONEncodeString(struct {
// Only strings have to be set, as the default is zero for ints.
// We do set the Type here because it is static anyway...
Type: 1,
Random: "0000000000000000",
Random: validatorPOWRandom,
Hash: "0000000000000000000000000000000000000000000000000000000000000000",
})

Expand All @@ -55,10 +57,17 @@ var _ = func() bool {
if len(validatorPOWHash) != hex.EncodedLen(h.Size()) {
panic("invalid pow hash length")
}
if !ValidSupportID([]byte(validatorPOWRandom[len(validatorPOWTimestamp):])) {
panic("invalid pow support ID placeholder")
}
return true
}()

func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
if !ValidSupportID(req.SupportID) {
return ErrInvalidLength
}

h := b.acquireHMAC()
defer b.releaseHMAC(h)

Expand All @@ -71,10 +80,13 @@ func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorRes
// 48 is the ASCII code for '0', adding lc.Countdown will give us the single correct digit.
copy(resp.Body.WriteNBytes(1), []byte{byte(48 + lc.Countdown)})
resp.Body.AdvanceW(len(`,"t":1,"r":"`))
timestampArea := resp.Body.WriteNBytes(len(validatorPOWRandom))
randomArea := resp.Body.WriteNBytes(len(validatorPOWRandom))
timestampArea := randomArea[:len(validatorPOWTimestamp)]
copy(randomArea[len(validatorPOWTimestamp):], req.SupportID)
resp.Body.AdvanceW(len(`","s":"`))
hexArea := resp.Body.WriteNBytes(hex.EncodedLen(h.Size()))
resp.Body.AdvanceW(len(`"}`))
resp.Body.AdvanceW(len(`"`))
appendSupportID(resp.Body, req.SupportID)

// we use the response body temporarily as a buffer
expireAt := tc.Now().Add(lc.Duration)
Expand All @@ -86,35 +98,41 @@ func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorRes

// Write identifier to hash to ensure uniqueness
req.Identifier.WriteTo(h)
h.Write(timestampArea)
h.Write(randomArea)

hex.Encode(hexArea, h.Sum(nil))

return nil
}

func (powValidator) isValid(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
// req.Body should be at least validatorPOWMinSolutionLength
if len(req.Body) <= validatorPOWMinSolutionLength {
// invalid solution data
req.SupportID = nil
if len(req.Body) < validatorPOWMinSolutionLength || len(req.Body) > validatorPOWMaxSolutionLength {
return ErrInvalidLength
}

body := buffer.NewSliceBufferWithSlice(req.Body)

timestampArea := body.ReadNBytes(len(validatorPOWRandom))
body.AdvanceR(1) // Skip padding character
randomArea := body.ReadNBytes(len(validatorPOWRandom))
if separator := body.ReadNBytes(1); len(separator) != 1 || separator[0] != '-' {
return ErrInvalidLength
}
sumArea := body.ReadNBytes(len(validatorPOWHash))
body.AdvanceR(1) // Skip padding character
if separator := body.ReadNBytes(1); len(separator) != 1 || separator[0] != '-' {
return ErrInvalidLength
}
solArea := body.ReadBytes()
for _, c := range solArea {
if c < '0' || c > '9' {
return ErrInvalidLength
}
}

h := b.acquireHMAC()
defer b.releaseHMAC(h)

// Write identifier to hash to ensure uniqueness
req.Identifier.WriteTo(h)

h.Write(timestampArea)
h.Write(randomArea)

// we use the response body temporarily as a buffer
defer resp.Body.Reset()
Expand All @@ -128,7 +146,14 @@ func (powValidator) isValid(b *Berghain, req *ValidatorRequest, resp *ValidatorR
}
resp.Body.Reset()

expirArea := resp.Body.WriteNBytes(hex.DecodedLen(len(validatorPOWRandom)))
supportID := randomArea[len(validatorPOWTimestamp):]
if !ValidSupportID(supportID) {
return ErrInvalidLength
}
req.SupportID = supportID
timestampArea := randomArea[:len(validatorPOWTimestamp)]

expirArea := resp.Body.WriteNBytes(hex.DecodedLen(len(validatorPOWTimestamp)))
if _, err := hex.Decode(expirArea, timestampArea); err != nil {
return err
}
Expand All @@ -141,7 +166,7 @@ func (powValidator) isValid(b *Berghain, req *ValidatorRequest, resp *ValidatorR
sha := acquireSHA256()
defer releaseSHA256(sha)

sha.Write(timestampArea)
sha.Write(randomArea)
sha.Write(solArea)
sum := sha.Sum(nil)

Expand Down
Loading
Loading