Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f703b72ce0 |
@@ -1439,7 +1439,7 @@ A registered user of the webhooker service.
|
||||
| Field | Type | Description |
|
||||
| ---------- | -------- | ----------- |
|
||||
| `id` | UUID | Primary key |
|
||||
| `username` | string | Unique login name, at most 1024 bytes so that it fits in the session cookie |
|
||||
| `username` | string | Unique login name |
|
||||
| `password` | string | Argon2id hash (never exposed via API) |
|
||||
|
||||
**Relations:** Has many Webhooks. Has many APIKeys.
|
||||
@@ -2379,14 +2379,14 @@ Removing either cap fails 14 subtests.
|
||||
|
||||
`internal/middleware/logbound_test.go` and
|
||||
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
|
||||
at each of these — just under 1 KB at `invalid password`, whose
|
||||
accounts are shared with the successful-login line and so must stay
|
||||
within the 1024-byte username limit — through both handlers, and
|
||||
through seven fills: plain text as the baseline, and then the
|
||||
quotation mark, backslash, tab, newline, C0 control and astral
|
||||
non-printable, six characters the wider of the two handlers spends
|
||||
more on than the client spent sending them. Every case holds each
|
||||
line to the 2,560-byte ceiling. That per-line ceiling
|
||||
at each of these — 1 KB at `invalid password`, whose accounts are
|
||||
shared with the successful-login line, where a username past 4 KB
|
||||
overflows the session cookie and answers 500 before that line is
|
||||
written — through both handlers, and through seven fills: plain text
|
||||
as the baseline, and then the quotation mark, backslash, tab, newline,
|
||||
C0 control and astral non-printable, six characters the wider of the
|
||||
two handlers spends more on than the client spent sending them. Every
|
||||
case holds each line to the 2,560-byte ceiling. That per-line ceiling
|
||||
is what the figure above states, and every row establishes it.
|
||||
|
||||
Three of the sites go further and bound the whole flood's output — the
|
||||
|
||||
@@ -79,9 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
||||
func DummyPasswordHashForTest() string {
|
||||
return dummyPasswordHash()
|
||||
}
|
||||
|
||||
// HashPasswordWithDefaultsForTest hashes with the shipped Argon2id
|
||||
// parameters, which TestMain has lowered for HashPassword itself.
|
||||
func HashPasswordWithDefaultsForTest(password string) (string, error) {
|
||||
return hashPasswordWith(password, DefaultPasswordConfig())
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestMain lowers the password hashing cost before any test runs. See
|
||||
// database.LowerPasswordHashCostForTest.
|
||||
func TestMain(m *testing.M) {
|
||||
database.LowerPasswordHashCostForTest()
|
||||
m.Run()
|
||||
}
|
||||
@@ -1,58 +1,13 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MaxUsernameBytes is the longest username, in bytes, that a user may
|
||||
// have. The same number appears in the check constraint on
|
||||
// User.Username, because a struct tag cannot reference a constant.
|
||||
//
|
||||
// A login stores the username in the session cookie, and both
|
||||
// securecookie and browsers refuse a cookie value past about 4096
|
||||
// bytes. That value is the session base64-encoded twice, so it holds
|
||||
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
|
||||
// timestamp and the session's other values take about 270 of those: a
|
||||
// username longer than about 2030 bytes can never log in. The limit is
|
||||
// about half that, so the session can carry more values later without
|
||||
// locking out an account whose username is already at the limit.
|
||||
const MaxUsernameBytes = 1024
|
||||
|
||||
// ErrUsernameTooLong is returned when a user is saved with a username
|
||||
// longer than MaxUsernameBytes.
|
||||
var ErrUsernameTooLong = errors.New("username is too long")
|
||||
|
||||
// User represents a user of the webhooker service
|
||||
//
|
||||
//nolint:lll // a struct tag cannot wrap
|
||||
type User struct {
|
||||
BaseModel
|
||||
|
||||
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
|
||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"` // Argon2 hashed
|
||||
|
||||
// Relations
|
||||
Webhooks []Webhook `json:"webhooks,omitempty"`
|
||||
APIKeys []APIKey `json:"apiKeys,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
|
||||
// User is created or saved, so those calls get ErrUsernameTooLong rather
|
||||
// than the database's constraint error. A column update such as
|
||||
// Update("username", ...) is caught only by the check constraint, as is
|
||||
// any path that writes the table without this model.
|
||||
func (u *User) BeforeSave(_ *gorm.DB) error {
|
||||
if len(u.Username) > MaxUsernameBytes {
|
||||
return fmt.Errorf(
|
||||
"%w: %d bytes, limit is %d",
|
||||
ErrUsernameTooLong,
|
||||
len(u.Username),
|
||||
MaxUsernameBytes,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
|
||||
// two-byte character. A check that counted characters rather than bytes
|
||||
// would see half the length and let the one-byte-longer name through.
|
||||
func usernameAtLimit() string {
|
||||
return strings.Repeat("é", database.MaxUsernameBytes/2)
|
||||
}
|
||||
|
||||
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
err := db.Create(&database.User{
|
||||
Username: usernameAtLimit() + "x",
|
||||
Password: "hash",
|
||||
}).Error
|
||||
|
||||
require.ErrorIs(t, err, database.ErrUsernameTooLong)
|
||||
}
|
||||
|
||||
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
require.NoError(t, db.Create(&database.User{
|
||||
Username: usernameAtLimit(),
|
||||
Password: "hash",
|
||||
}).Error)
|
||||
}
|
||||
|
||||
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
|
||||
// SQL, as a path that bypassed User.BeforeSave would, so only the
|
||||
// table's check constraint stands between it and an over-long
|
||||
// username. Accepting the name at the limit and refusing the next byte
|
||||
// also pins the constraint's number to MaxUsernameBytes.
|
||||
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
|
||||
|
||||
require.NoError(t, db.Exec(
|
||||
insert, uuid.New().String(), usernameAtLimit(), "hash",
|
||||
).Error)
|
||||
|
||||
err := db.Exec(
|
||||
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
|
||||
).Error
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "CHECK constraint failed")
|
||||
}
|
||||
@@ -63,24 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// hashConfig is what HashPassword hashes with: the defaults above.
|
||||
// Only a test binary changes it, through LowerPasswordHashCostForTest,
|
||||
// before any test runs.
|
||||
//
|
||||
//nolint:gochecknoglobals // see above
|
||||
var hashConfig = DefaultPasswordConfig()
|
||||
|
||||
// HashPassword generates an Argon2id hash of the password
|
||||
func HashPassword(password string) (string, error) {
|
||||
return hashPasswordWith(password, hashConfig)
|
||||
}
|
||||
config := DefaultPasswordConfig()
|
||||
|
||||
// hashPasswordWith generates an Argon2id hash of the password with
|
||||
// the given parameters.
|
||||
func hashPasswordWith(
|
||||
password string,
|
||||
config *PasswordConfig,
|
||||
) (string, error) {
|
||||
// Generate a salt
|
||||
salt := make([]byte, config.SaltLen)
|
||||
|
||||
|
||||
@@ -192,36 +192,6 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPassword_ShippedParameters hashes and verifies at the
|
||||
// shipped Argon2id parameters. Every other test hashes at the lowered
|
||||
// memory cost TestMain sets, so this is the one that keeps production
|
||||
// hashing covered. One hash and one verification: each costs 64 MB.
|
||||
func TestHashPassword_ShippedParameters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password := "correct horse battery staple"
|
||||
|
||||
hash, err := database.HashPasswordWithDefaultsForTest(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hashing with the shipped parameters: %v", err)
|
||||
}
|
||||
|
||||
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
||||
|
||||
if !strings.HasPrefix(hash, shipped) {
|
||||
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
||||
}
|
||||
|
||||
valid, err := database.VerifyPassword(password, hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
t.Error("VerifyPassword() returned false for correct password")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||
// path. Login charges an unknown username a verification against a
|
||||
// dummy hash so that a nonexistent account is not answered in
|
||||
|
||||
@@ -45,20 +45,3 @@ func NewTestWebhookDBManagerWithLogger(
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// testArgon2Memory is the Argon2id memory cost, in KiB, that test
|
||||
// binaries hash with: 1 MB instead of the shipped 64 MB.
|
||||
const testArgon2Memory = 1024
|
||||
|
||||
// LowerPasswordHashCostForTest makes HashPassword use a 1 MB Argon2id
|
||||
// memory cost instead of the shipped 64 MB, for the rest of the
|
||||
// process. Call it from TestMain, before any test runs.
|
||||
//
|
||||
// Every test that starts a database hashes the bootstrap admin
|
||||
// password, and a package runs dozens of those tests in parallel.
|
||||
// Under the race detector each 64 MB hash holds about 150 MB resident.
|
||||
// VerifyPassword reads the cost from the hash it checks, so
|
||||
// verification follows. Production code never calls this.
|
||||
func LowerPasswordHashCostForTest() {
|
||||
hashConfig.Memory = testArgon2Memory
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package gormlog_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestMain lowers the password hashing cost before any test runs. See
|
||||
// database.LowerPasswordHashCostForTest.
|
||||
func TestMain(m *testing.M) {
|
||||
database.LowerPasswordHashCostForTest()
|
||||
m.Run()
|
||||
}
|
||||
@@ -453,33 +453,3 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
|
||||
"the issued cookie must carry an authenticated session",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLogin_UsernameAtLimitCanLogIn shows that a username of exactly
|
||||
// database.MaxUsernameBytes still fits in the session cookie. Past
|
||||
// what the cookie can carry, a correct login answers 500.
|
||||
func TestLogin_UsernameAtLimitCanLogIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
h *handlers.Handlers
|
||||
db *database.Database
|
||||
)
|
||||
|
||||
app := newTestApp(t, &h, &db)
|
||||
app.RequireStart()
|
||||
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
username := strings.Repeat("a", database.MaxUsernameBytes)
|
||||
|
||||
hash, err := database.HashPassword(operatorPassword)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.DB().Create(&database.User{
|
||||
Username: username,
|
||||
Password: hash,
|
||||
}).Error)
|
||||
|
||||
w := submitLogin(h, sharedProxyPeer, username, operatorPassword)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||
}
|
||||
|
||||
@@ -339,9 +339,11 @@ const storedUserPassword = "correct-horse-battery-staple"
|
||||
// storedFillBytes is the raw length of the client-chosen value in
|
||||
// those accounts' usernames. It is well past the 512-byte field
|
||||
// budget, so the line is still truncated, but short enough that the
|
||||
// whole username, markers and fill name included, stays within
|
||||
// database.MaxUsernameBytes.
|
||||
const storedFillBytes = 960
|
||||
// session cookie a successful login writes stays inside
|
||||
// securecookie's 4 KB limit: the cookie is written BEFORE the
|
||||
// "user logged in" line, so an 8 KB username answers 500 and never
|
||||
// reaches it.
|
||||
const storedFillBytes = 1024
|
||||
|
||||
// storedFill builds a username fill of storedFillBytes raw bytes out
|
||||
// of repetitions of ch, with both markers at its far end.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestMain lowers the password hashing cost before any test runs. See
|
||||
// database.LowerPasswordHashCostForTest.
|
||||
func TestMain(m *testing.M) {
|
||||
database.LowerPasswordHashCostForTest()
|
||||
m.Run()
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package resetpw_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestMain lowers the password hashing cost before any test runs. See
|
||||
// database.LowerPasswordHashCostForTest.
|
||||
func TestMain(m *testing.M) {
|
||||
database.LowerPasswordHashCostForTest()
|
||||
m.Run()
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
||||
// and the database, exactly as internal/handlers builds them.
|
||||
//
|
||||
// One application per test function, not per case: every start that
|
||||
// finds no account seeds one with an Argon2id hash, and this package's
|
||||
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||
// budget is not the place to spend that repeatedly.
|
||||
func newServerApp(
|
||||
t *testing.T, dir string,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// TestMain lowers the password hashing cost before any test runs. See
|
||||
// database.LowerPasswordHashCostForTest.
|
||||
func TestMain(m *testing.M) {
|
||||
database.LowerPasswordHashCostForTest()
|
||||
m.Run()
|
||||
}
|
||||
+1
-6
@@ -22,18 +22,13 @@
|
||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||
# 67s, that is the datum to revisit the org figure with.
|
||||
#
|
||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
||||
# -race every test binary and every link costs a few hundred MB, so the
|
||||
# defaults (one per core) add up to several GB on a many-core host.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
||||
go test -v -race -timeout 90s ./...
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user