Files
webhooker/internal/resetpw/resetpw_test.go
sneak 15ada44902
All checks were successful
check / check (push) Successful in 2m54s
Add an egress CIDR allowlist to the SSRF guard (closes #204)
The SSRF blocklist had no escape hatch, so the thing webhooker is
mostly for — taking a public webhook and forwarding it to something
on your own network — could not be configured at all. Every private
address, Docker sibling and loopback service was permanently
unreachable as a delivery destination.

ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery
targets may reach despite the default blocklist. It is an allowlist
and only ever adds destinations: there is no boolean, and no value
disables SSRF protection wholesale. Empty, the guard behaves exactly
as before.

A fixed set of addresses is refused before the allowlist is
consulted, so no supplied CIDR opens one — not the exact address,
not a supernet, not 0.0.0.0/0 or ::/0. It is the two link-local
blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud
metadata endpoints that sit outside them: AWS's IPv6 IMDS at
fd00:ec2::254, which lives in ordinary ULA space, and Alibaba's
100.100.100.200, which lives in CGNAT. Allowlisting fd00::/8 or
100.64.0.0/10 (Tailscale's range) is an ordinary thing for an
operator to do and must not reopen instance-credential theft. The
IPv4-compatible (::a9fe:a9fe) and NAT64 (64:ff9b::a9fe:a9fe)
spellings of 169.254.169.254 are listed too, because To4() does not
normalise them into the link-local block the way it does the
IPv4-mapped form. Reaching any of these is credential theft rather
than delivery to an internal service.

The policy now lives in one function, Guard.checkIP, which both
target-creation validation and the delivery dialer call. The two
paths previously decided separately, which is how they came to
disagree about a destination. The guard is built once from config
and injected via fx into both the handlers and the delivery engine,
so there is a single instance and a single answer.

A set-but-unparseable value aborts startup naming the variable,
reusing the existing envPrefixList parser. A non-empty list is
logged at startup with the blocks spelled out, not counted, so the
hole is visible in the log of any deployment that has one.

Tests: an allowlisted loopback CIDR both validates and delivers to a
live server (and the same URL still fails without the allowlist); a
private address outside the listed block stays refused on both
paths; every unconditionally blocked address stays refused on both
paths under an allowlist that covers it, and the set itself is
pinned entry by entry; public addresses are unaffected either way;
and config coverage for parsing, startup abort, and the warning's
contents.
2026-08-20 06:28:54 +00:00

445 lines
12 KiB
Go

package resetpw_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/session"
)
const (
// operatorUser is the account these tests recover.
operatorUser = "admin"
// newPassword is what the operator sets it to.
newPassword = "correct horse battery staple"
// placeholderHash stands in for the stored credential nobody
// knows any more — the lost bootstrap password. Nothing here
// verifies against it; what matters is whether it is still there
// after a refusal, or replaced after a reset.
placeholderHash = "$argon2id$lost"
// exitOK and exitFailure are the statuses Run returns.
exitOK = 0
exitFailure = 1
)
// testLogger is quiet unless something goes wrong.
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelWarn,
}))
}
// newDeployment builds a data directory holding a migrated database
// with one account whose password is unknown, and points DATA_DIR at
// it. It deliberately does not boot the server graph: seeding through
// it would spend an Argon2id hash on a password no test can use.
func newDeployment(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
db, err := database.Open(dir, testLogger())
require.NoError(t, err)
require.NoError(t, db.DB().Create(&database.User{
Username: operatorUser,
Password: placeholderHash,
}).Error)
require.NoError(t, db.Close())
return dir
}
// storedHash reads the account's stored credential back.
func storedHash(t *testing.T, dir string) string {
t.Helper()
db, err := database.Open(dir, testLogger())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
var user database.User
require.NoError(t, db.DB().
Where("username = ?", operatorUser).
First(&user).Error)
return user.Password
}
// bannerPassword returns the plaintext a credentials banner printed.
func bannerPassword(t *testing.T, out string) string {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
_, value, found := strings.Cut(line, "password: ")
if found {
return strings.TrimSpace(value)
}
}
t.Fatalf("no password line in:\n%s", out)
return ""
}
// run drives the subcommand with the given standard input and returns
// its status alongside what it wrote.
func run(
t *testing.T, stdin string, args ...string,
) (int, string, string) {
t.Helper()
var stdout, stderr bytes.Buffer
code := resetpw.Run(
args, strings.NewReader(stdin), &stdout, &stderr,
)
return code, stdout.String(), stderr.String()
}
type noopNotifier struct{}
func (n *noopNotifier) Notify([]delivery.Task) {}
type noopEvictor struct{}
func (n *noopEvictor) EvictWebhook(string) {}
// newServerApp starts the real login path against dir: the handlers,
// the middleware that bounds password verification, the session store
// 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 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,
) (*handlers.Handlers, *fxtest.App) {
t.Helper()
var h *handlers.Handlers
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{DataDir: dir}
},
database.New,
database.NewWebhookDBManager,
healthcheck.New,
session.New,
func() delivery.Notifier { return &noopNotifier{} },
func() delivery.WebhookEvictor { return &noopEvictor{} },
middleware.New,
delivery.NewGuard,
handlers.New,
),
fx.Populate(&h),
)
app.RequireStart()
return h, app
}
// submitLogin drives one login form POST through the real handler.
func submitLogin(
h *handlers.Handlers, username, password string,
) *httptest.ResponseRecorder {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/pages/login",
strings.NewReader(form.Encode()),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
req.RemoteAddr = "10.0.0.1:44444"
w := httptest.NewRecorder()
h.HandleLoginSubmit().ServeHTTP(w, req)
return w
}
// TestResetThenLogin is the definition of done of
// https://git.eeqj.de/sneak/webhooker/issues/208: an operator who lost
// the one-time bootstrap password sets a new one from the command line
// and logs in with it.
//
// The login is the real one — the form POST through
// handlers.HandleLoginSubmit, which looks the account up and verifies
// the stored Argon2id hash — so a reset that wrote a hash the login
// path cannot verify fails here rather than passing a re-implementation
// of the check.
//
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
func TestResetThenLogin(t *testing.T) {
dir := newDeployment(t)
code, stdout, stderr := run(t, newPassword+"\n", operatorUser)
require.Equal(t, exitOK, code, "stderr: %s", stderr)
assert.NotContains(
t, stdout, newPassword,
"a password the operator supplied must not be echoed back",
)
assert.Contains(t, stdout, operatorUser)
h, app := newServerApp(t, dir)
defer app.RequireStop()
got := submitLogin(h, operatorUser, newPassword)
require.Equal(
t, http.StatusSeeOther, got.Code,
"the new password must log in",
)
got = submitLogin(h, operatorUser, "not-"+newPassword)
require.NotEqual(
t, http.StatusSeeOther, got.Code,
"the reset must not make every password work",
)
}
// TestGeneratedPasswordIsPrintedAndWorks covers -generate, the mode an
// operator recovering a deployment actually reaches for. The generated
// password is shown once, in the banner, and must be the one stored.
//
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
func TestGeneratedPasswordIsPrintedAndWorks(t *testing.T) {
dir := newDeployment(t)
code, stdout, stderr := run(t, "", "-generate", operatorUser)
require.Equal(t, exitOK, code, "stderr: %s", stderr)
require.Contains(
t, stdout, strings.Repeat("=", 20),
"a generated password must be printed as a banner",
)
password := bannerPassword(t, stdout)
ok, err := database.VerifyPassword(password, storedHash(t, dir))
require.NoError(t, err)
assert.True(
t, ok, "the printed password must open the account",
)
}
// failingWriter is a standard output that cannot be written to.
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, assert.AnError
}
// TestGeneratedPasswordSurvivesAFailedStdout covers the one outcome
// worse than an error: the password is already stored, so a banner
// that cannot be written to standard output must go to standard error
// rather than leaving the deployment behind a password nobody has seen.
//
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
func TestGeneratedPasswordSurvivesAFailedStdout(t *testing.T) {
dir := newDeployment(t)
var stderr bytes.Buffer
code := resetpw.Run(
[]string{"-generate", operatorUser},
strings.NewReader(""), failingWriter{}, &stderr,
)
require.Equal(t, exitOK, code)
password := bannerPassword(t, stderr.String())
ok, err := database.VerifyPassword(password, storedHash(t, dir))
require.NoError(t, err)
assert.True(t, ok)
}
// TestRefusesLiveInstance pins the refusal the issue requires. The
// running deployment keeps serving the sessions that authenticated
// with the old password, so a reset underneath it would report a
// change the service does not honour.
//
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
func TestRefusesLiveInstance(t *testing.T) {
dir := newDeployment(t)
lock, err := datadir.Acquire(dir)
require.NoError(t, err)
defer func() { require.NoError(t, lock.Release()) }()
code, _, stderr := run(t, newPassword+"\n", operatorUser)
require.Equal(t, exitFailure, code)
assert.Contains(t, stderr, dir, "the refusal must name DATA_DIR")
assert.Contains(t, stderr, "running webhooker")
assert.Equal(
t, placeholderHash, storedHash(t, dir),
"a refused reset must not touch the stored credential",
)
}
// TestMissingDataDirCreatesNothing pins the side effect that must not
// happen. datadir.Acquire calls os.MkdirAll, so reaching it with a
// mistyped DATA_DIR would build the directory, take a lock in it and
// migrate an empty database there — reporting success against a
// deployment that does not exist.
func TestMissingDataDirCreatesNothing(t *testing.T) {
dir := filepath.Join(t.TempDir(), "typo", "webhooker")
t.Setenv("DATA_DIR", dir)
code, _, stderr := run(t, newPassword+"\n", operatorUser)
require.Equal(t, exitFailure, code)
assert.Contains(t, stderr, dir)
_, err := os.Stat(dir)
assert.ErrorIs(
t, err, os.ErrNotExist,
"a mistyped DATA_DIR must not be created",
)
}
// TestMissingDatabaseCreatesNothing covers the directory that exists
// but holds no deployment: an empty volume, or the wrong one. Nothing
// may be written there either, lock file included.
func TestMissingDatabaseCreatesNothing(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
code, _, stderr := run(t, newPassword+"\n", operatorUser)
require.Equal(t, exitFailure, code)
assert.Contains(t, stderr, database.MainDBFileName)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(
t, entries,
"nothing may be created in a directory holding no database",
)
}
// TestUnknownUserFails states the decision: resetpw changes an
// existing account's password and never creates an account. A typo in
// the username must say so rather than quietly adding a second user.
//
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
func TestUnknownUserFails(t *testing.T) {
dir := newDeployment(t)
code, _, stderr := run(t, newPassword+"\n", "amdin")
require.Equal(t, exitFailure, code)
assert.Contains(t, stderr, "amdin")
assert.Equal(t, placeholderHash, storedHash(t, dir))
var count int64
db, err := database.Open(dir, testLogger())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, db.DB().Model(&database.User{}).
Count(&count).Error)
assert.EqualValues(
t, 1, count, "no account may have been created",
)
}
// TestRejectsUnusablePasswords covers what standard input can carry by
// accident: nothing at all, and a stray keystroke. Either would
// otherwise become the account's only credential.
//
//nolint:paralleltest // newDeployment sets DATA_DIR with t.Setenv.
func TestRejectsUnusablePasswords(t *testing.T) {
dir := newDeployment(t)
for name, stdin := range map[string]string{
"empty": "",
"newline": "\n",
"short": "hunter2\n",
} {
t.Run(name, func(t *testing.T) {
code, _, stderr := run(t, stdin, operatorUser)
require.Equal(t, exitFailure, code)
assert.NotEmpty(t, stderr)
assert.Equal(
t, placeholderHash, storedHash(t, dir),
"a rejected password must not be stored",
)
})
}
}
// TestUsageErrors pins the statuses a caller can script against: 2 for
// being called wrong, which is not the same as a refusal.
func TestUsageErrors(t *testing.T) {
t.Parallel()
for name, args := range map[string][]string{
"no username": {},
"two usernames": {"admin", "root"},
"unknown flag": {"-force", "admin"},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
code := resetpw.Run(
args, strings.NewReader(""), &stdout, &stderr,
)
assert.Equal(t, 2, code)
assert.NotEmpty(t, stderr)
})
}
}