Author SHA1 Message Date
clawbot 7b09802967 State what the default blocklist covers (closes #244)
check / check (push) Successful in 4m23s
The default blocklist covers private and reserved space, plus public
addresses that serve cloud credentials. A provider's other services on
public addresses, such as IBM Cloud's 161.26.0.0/16 and 166.8.0.0/14,
are deliberately not on it: they serve no credentials, reaching them
can be legitimate, and every cloud has some, so a partial list would
promise coverage it does not give.

The README's egress section and the comment above blockedNetworks now
state this rule, so nobody infers wider coverage and a future candidate
can be accepted or refused against it. No list change.

Model: opus-5-5
2026-09-29 08:44:00 +00:00
6 changed files with 30 additions and 153 deletions
+17 -9
View File
@@ -162,6 +162,14 @@ public cloud metadata addresses: currently only `168.63.129.16`, Azure's
WireServer, which serves an Azure VM its credentials. Because it is a WireServer, which serves an Azure VM its credentials. Because it is a
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
That is all the default blocklist covers: private and reserved space,
plus public addresses that serve cloud credentials. A cloud provider's
other services on public addresses are not refused — IBM Cloud's
`161.26.0.0/16` and `166.8.0.0/14`, for example, which carry its DNS
resolvers, time servers and package mirrors. They serve no credentials,
reaching them can be a legitimate delivery, and every cloud has some, so
a partial list would promise coverage it does not give.
That default is also inconvenient for the thing webhooker is mostly That default is also inconvenient for the thing webhooker is mostly
for: taking a public webhook and forwarding it to something on your own for: taking a public webhook and forwarding it to something on your own
network. A container on the same Docker network, a box on `10.x`, a network. A container on the same Docker network, a box on `10.x`, a
@@ -1472,7 +1480,7 @@ A registered user of the webhooker service.
| Field | Type | Description | | Field | Type | Description |
| ---------- | -------- | ----------- | | ---------- | -------- | ----------- |
| `id` | UUID | Primary key | | `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) | | `password` | string | Argon2id hash (never exposed via API) |
**Relations:** Has many Webhooks. Has many APIKeys. **Relations:** Has many Webhooks. Has many APIKeys.
@@ -2412,14 +2420,14 @@ Removing either cap fails 14 subtests.
`internal/middleware/logbound_test.go` and `internal/middleware/logbound_test.go` and
`internal/handlers/logbound_test.go` drive 8 KB of client-chosen text `internal/handlers/logbound_test.go` drive 8 KB of client-chosen text
at each of these — just under 1 KB at `invalid password`, whose at each of these — 1 KB at `invalid password`, whose accounts are
accounts are shared with the successful-login line and so must stay shared with the successful-login line, where a username past 4 KB
within the 1024-byte username limit — through both handlers, and overflows the session cookie and answers 500 before that line is
through seven fills: plain text as the baseline, and then the written — through both handlers, and through seven fills: plain text
quotation mark, backslash, tab, newline, C0 control and astral as the baseline, and then the quotation mark, backslash, tab, newline,
non-printable, six characters the wider of the two handlers spends C0 control and astral non-printable, six characters the wider of the
more on than the client spent sending them. Every case holds each two handlers spends more on than the client spent sending them. Every
line to the 2,560-byte ceiling. That per-line ceiling 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. 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 Three of the sites go further and bound the whole flood's output — the
+1 -45
View File
@@ -1,57 +1,13 @@
package database 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 // User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct { type User struct {
BaseModel 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 Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations // Relations
Webhooks []Webhook `json:"webhooks,omitempty"` Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"` APIKeys []APIKey `json:"apiKeys,omitempty"`
} }
// BeforeSave rejects a username longer than MaxUsernameBytes, so every
// path that saves a user through GORM gets ErrUsernameTooLong rather
// than the database's constraint error. The check constraint behind it
// holds for 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
}
-65
View File
@@ -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")
}
+6
View File
@@ -43,6 +43,12 @@ var (
// permit specific blocks out of this set with // permit specific blocks out of this set with
// ALLOWED_EGRESS_CIDRS; see Guard. // ALLOWED_EGRESS_CIDRS; see Guard.
// //
// A public address belongs here only if it serves cloud
// credentials; a provider's other services on public addresses,
// such as its DNS resolvers or package mirrors, stay out, since
// reaching them can be legitimate and no list of them could be
// complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here //nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet var blockedNetworks []*net.IPNet
-30
View File
@@ -453,33 +453,3 @@ func TestLogin_SuccessCreatesSession(t *testing.T) {
"the issued cookie must carry an authenticated session", "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)
}
+5 -3
View File
@@ -339,9 +339,11 @@ const storedUserPassword = "correct-horse-battery-staple"
// storedFillBytes is the raw length of the client-chosen value in // storedFillBytes is the raw length of the client-chosen value in
// those accounts' usernames. It is well past the 512-byte field // those accounts' usernames. It is well past the 512-byte field
// budget, so the line is still truncated, but short enough that the // budget, so the line is still truncated, but short enough that the
// whole username, markers and fill name included, stays within // session cookie a successful login writes stays inside
// database.MaxUsernameBytes. // securecookie's 4 KB limit: the cookie is written BEFORE the
const storedFillBytes = 960 // "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 // storedFill builds a username fill of storedFillBytes raw bytes out
// of repetitions of ch, with both markers at its far end. // of repetitions of ch, with both markers at its far end.