All checks were successful
check / check (push) Successful in 3m4s
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, it adds nothing and the guard permits and refuses the same addresses it did before, save the two spellings named below. A small 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. alwaysBlockedNetworks in internal/delivery/ssrf.go is the authoritative list and states the membership criterion in full; it is deliberately not copied here, because a copy drifts out of date. In short: the provider fixes the address, so a host route for it collides with nothing the operator runs, and reaching it discloses credentials or user data. A publicly routable address never qualifies however well it meets both — nothing in this set can be reopened, so blocking one here would leave the operator no escape hatch at all. Those belong in blockedNetworks, which an allowlist can override. Every entry is already inside the default blocklist, which is what makes it unconditional rather than newly blocked, with two exceptions that are the one behaviour change visible when the allowlist is unset: ::a9fe:a9fe and 64:ff9b::a9fe:a9fe, the IPv4-compatible and NAT64 spellings of 169.254.169.254, were reachable before and are refused now. net.IPNet.Contains normalises only the IPv4-mapped form via To4(), so 169.254.0.0/16 never matched those two. The ten it does cover now report a metadata error rather than the generic private-range one. 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)
|
|
})
|
|
}
|
|
}
|