All checks were successful
check / check (push) Successful in 2m54s
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.
445 lines
12 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|