Spend a hashcash stamp only when the request succeeds (closes #158)
check / check (push) Waiting to run
check / check (push) Waiting to run
A session request refused with 400 or 409, or a +H channel message refused for membership, a ban or +m, used to spend its stamp, so a retry with the same stamp was refused as already used. hashcash.Validator.Validate is split into Check, which spends nothing, and Claim, which records the stamp; Release undoes a claim. Session creation checks the stamp first, claims it just before CreateSession and releases it if CreateSession fails. SendChannelMessage checks the stamp after the membership, ban and +m checks. Model: opus-5-5
This commit is contained in:
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
return
|
||||
}
|
||||
|
||||
if !hdlr.validateHashcash(
|
||||
if !hdlr.checkHashcash(
|
||||
writer, request, payload.Hashcash,
|
||||
) {
|
||||
return
|
||||
@@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession(
|
||||
}
|
||||
|
||||
hdlr.executeCreateSession(
|
||||
writer, request, payload.Nick, username,
|
||||
writer, request,
|
||||
payload.Nick, username, payload.Hashcash,
|
||||
)
|
||||
}
|
||||
|
||||
func (hdlr *Handlers) executeCreateSession(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
nick, username string,
|
||||
nick, username, stamp string,
|
||||
) {
|
||||
remoteIP := clientIP(request)
|
||||
|
||||
@@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession(
|
||||
request.Context(), remoteIP,
|
||||
)
|
||||
|
||||
if !hdlr.claimHashcash(writer, request, stamp) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, clientID, token, err :=
|
||||
hdlr.params.Database.CreateSession(
|
||||
request.Context(),
|
||||
nick, username, hostname, remoteIP,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.hashcashVal.Release(stamp)
|
||||
hdlr.handleCreateSessionError(
|
||||
writer, request, err,
|
||||
)
|
||||
@@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession(
|
||||
}, http.StatusCreated)
|
||||
}
|
||||
|
||||
// validateHashcash validates a hashcash stamp if required.
|
||||
// Returns false if validation failed and a response was
|
||||
// already sent.
|
||||
func (hdlr *Handlers) validateHashcash(
|
||||
// checkHashcash checks a hashcash stamp if required,
|
||||
// without spending it. Returns false if the check failed
|
||||
// and a response was already sent.
|
||||
func (hdlr *Handlers) checkHashcash(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
stamp string,
|
||||
@@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash(
|
||||
return false
|
||||
}
|
||||
|
||||
err := hdlr.hashcashVal.Validate(
|
||||
err := hdlr.hashcashVal.Check(
|
||||
stamp, hdlr.params.Config.HashcashBits,
|
||||
)
|
||||
if err != nil {
|
||||
hdlr.respondError(
|
||||
writer, request,
|
||||
"invalid hashcash stamp: "+err.Error(),
|
||||
http.StatusPaymentRequired,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// claimHashcash spends a stamp that checkHashcash accepted.
|
||||
// Returns false if Claim refused the stamp, normally because
|
||||
// it was already spent, and a response was already sent.
|
||||
func (hdlr *Handlers) claimHashcash(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
stamp string,
|
||||
) bool {
|
||||
err := hdlr.hashcashVal.Claim(
|
||||
stamp, hdlr.params.Config.HashcashBits,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"modernc.org/sqlite"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
@@ -4073,6 +4074,114 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// postSessionWithStamp posts a session request for nick and
|
||||
// username carrying stamp and returns the response status.
|
||||
// An empty username defaults to the nick.
|
||||
func postSessionWithStamp(
|
||||
t *testing.T,
|
||||
tserver *testServer,
|
||||
nick, username, stamp string,
|
||||
) int {
|
||||
t.Helper()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
nickKey: nick, "username": username, "pow_token": stamp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
resp, err := doRequest(
|
||||
t, http.MethodPost, tserver.url(apiSession),
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_ = resp.Body.Close()
|
||||
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// sessionCount returns the number of sessions in tserver's
|
||||
// database.
|
||||
func sessionCount(t *testing.T, tserver *testServer) int64 {
|
||||
t.Helper()
|
||||
|
||||
count, err := tserver.database.GetUserCount(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("count sessions: %v", err)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// TestRefusedSessionLeavesStampUnspent checks that a
|
||||
// session request is refused with 402 for an invalid stamp
|
||||
// before its nick is looked at, that a request refused with
|
||||
// 400 or 409 does not spend its stamp, so the same stamp
|
||||
// then creates a session, that the stamp stays spent after
|
||||
// that, and that no refused request creates a session.
|
||||
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
||||
const bits = 2
|
||||
|
||||
tserver := newTestServerWith(t, bits, "", "")
|
||||
|
||||
// A server with no SERVER_NAME takes stamps for "neoirc".
|
||||
status := postSessionWithStamp(
|
||||
t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"),
|
||||
)
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("create taken: want 201, got %d", status)
|
||||
}
|
||||
|
||||
stamp := api.MintHashcash(bits, "neoirc")
|
||||
otherServerStamp := api.MintHashcash(bits, "elsewhere")
|
||||
|
||||
steps := []struct {
|
||||
nick, username, stamp string
|
||||
want int
|
||||
}{
|
||||
{"bad nick", "", otherServerStamp, http.StatusPaymentRequired},
|
||||
{"bad nick", "", stamp, http.StatusBadRequest},
|
||||
{"fresh", "bad username", stamp, http.StatusBadRequest},
|
||||
{"taken", "", stamp, http.StatusConflict},
|
||||
{"fresh", "", stamp, http.StatusCreated},
|
||||
{"another", "", stamp, http.StatusPaymentRequired},
|
||||
// Refused as already spent, the stamp is not given back.
|
||||
{"later", "", stamp, http.StatusPaymentRequired},
|
||||
// The refused request for "another" left the nick free.
|
||||
{"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated},
|
||||
}
|
||||
|
||||
sessions := sessionCount(t, tserver)
|
||||
|
||||
for _, step := range steps {
|
||||
status = postSessionWithStamp(
|
||||
t, tserver, step.nick, step.username, step.stamp,
|
||||
)
|
||||
if status != step.want {
|
||||
t.Fatalf(
|
||||
"nick %q, username %q: want %d, got %d",
|
||||
step.nick, step.username, step.want, status,
|
||||
)
|
||||
}
|
||||
|
||||
if status == http.StatusCreated {
|
||||
sessions++
|
||||
}
|
||||
|
||||
count := sessionCount(t, tserver)
|
||||
if count != sessions {
|
||||
t.Fatalf(
|
||||
"nick %q, username %q: want %d sessions, got %d",
|
||||
step.nick, step.username, sessions, count,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesShowsHostmask(t *testing.T) {
|
||||
tserver := newTestServer(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user