All checks were successful
check / check (push) Successful in 3m28s
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.
793 lines
21 KiB
Go
793 lines
21 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"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/server"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
"sneak.berlin/go/webhooker/static"
|
|
)
|
|
|
|
// csrfCookieName is the cookie gorilla/csrf issues when it runs. Its
|
|
// presence or absence on a response is how these tests tell whether
|
|
// the CSRF middleware executed.
|
|
const csrfCookieName = "_gorilla_csrf"
|
|
|
|
const (
|
|
// metricsUser and metricsAuthValue are the /metrics basic-auth
|
|
// credentials the metrics routing tests below configure.
|
|
metricsUser = "metrics"
|
|
metricsAuthValue = "s3cret"
|
|
)
|
|
|
|
type noopNotifier struct{}
|
|
|
|
func (n *noopNotifier) Notify([]delivery.Task) {}
|
|
|
|
// noopEvictor satisfies handlers.New's delivery.WebhookEvictor
|
|
// dependency. These tests never delete a webhook, so there is
|
|
// nothing to record.
|
|
type noopEvictor struct{}
|
|
|
|
func (e *noopEvictor) EvictWebhook(string) {}
|
|
|
|
// testEnv is the real router from routes.go plus the collaborators
|
|
// tests need to seed users and forge sessions.
|
|
type testEnv struct {
|
|
router http.Handler
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
|
|
// The collaborators the router was built from, kept so a test
|
|
// that needs a second router over the same graph — one carrying
|
|
// a panicking probe route, or one with Sentry registered — can
|
|
// build it without wiring the graph again.
|
|
log *logger.Logger
|
|
cfg *config.Config
|
|
mw *middleware.Middleware
|
|
hnd *handlers.Handlers
|
|
}
|
|
|
|
// newTestEnv wires the dependency graph with fx and builds the
|
|
// production route tree, so middleware registration order is
|
|
// exercised exactly as it ships.
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithConfig(t, &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
})
|
|
}
|
|
|
|
// newTestEnvWithConfig is newTestEnv over a caller-supplied Config,
|
|
// for the routes whose existence the configuration decides. The same
|
|
// pointer reaches the router and every middleware, so a test cannot
|
|
// accidentally configure one and not the other.
|
|
func newTestEnvWithConfig(
|
|
t *testing.T, cfg *config.Config,
|
|
) *testEnv {
|
|
t.Helper()
|
|
|
|
var (
|
|
log *logger.Logger
|
|
mw *middleware.Middleware
|
|
hnd *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := fxtest.New(
|
|
t,
|
|
fx.Provide(
|
|
globals.New,
|
|
logger.New,
|
|
func() *config.Config { return cfg },
|
|
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(&log, &mw, &hnd, &sess, &db, &dbMgr),
|
|
)
|
|
app.RequireStart()
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
return &testEnv{
|
|
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
|
sess: sess,
|
|
db: db,
|
|
dbMgr: dbMgr,
|
|
log: log,
|
|
cfg: cfg,
|
|
mw: mw,
|
|
hnd: hnd,
|
|
}
|
|
}
|
|
|
|
// oversizeValue returns a form value one byte past the route-group
|
|
// body cap, so an encoded form containing it is guaranteed oversize.
|
|
func oversizeValue() string {
|
|
return strings.Repeat("a", int(server.MaxFormBodySizeForTest)+1)
|
|
}
|
|
|
|
// csrfCookieSet reports whether the response issued a gorilla/csrf
|
|
// cookie, which only happens if the CSRF middleware ran.
|
|
func csrfCookieSet(w *httptest.ResponseRecorder) bool {
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == csrfCookieName {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// get issues a GET through the router with the supplied cookies.
|
|
func (e *testEnv) get(
|
|
path string,
|
|
cookies []*http.Cookie,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, path, nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
e.router.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// post issues a urlencoded form POST through the router. The body is
|
|
// a strings.Reader, so the request carries an accurate
|
|
// Content-Length — the signal MaxBodySize checks up front.
|
|
func (e *testEnv) post(
|
|
path string,
|
|
form url.Values,
|
|
cookies []*http.Cookie,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, path,
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
req.Header.Set(
|
|
"Content-Type", "application/x-www-form-urlencoded",
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
e.router.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// csrfFrom renders the page at path and returns the CSRF token from
|
|
// its form together with every cookie needed for the follow-up POST.
|
|
func (e *testEnv) csrfFrom(
|
|
t *testing.T,
|
|
path string,
|
|
cookies []*http.Cookie,
|
|
) (string, []*http.Cookie) {
|
|
t.Helper()
|
|
|
|
w := e.get(path, cookies)
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
pattern := regexp.MustCompile(
|
|
`name="csrf_token" value="([^"]+)"`,
|
|
)
|
|
|
|
match := pattern.FindStringSubmatch(w.Body.String())
|
|
require.Len(t, match, 2, "form must embed a CSRF token")
|
|
|
|
// html/template escapes "+" and "=" in attribute values, and
|
|
// gorilla/csrf tokens are standard base64, so the value read
|
|
// out of the markup has to be unescaped before it is submitted.
|
|
token := html.UnescapeString(match[1])
|
|
|
|
combined := make([]*http.Cookie, 0, len(cookies))
|
|
combined = append(combined, cookies...)
|
|
combined = append(combined, w.Result().Cookies()...)
|
|
|
|
return token, combined
|
|
}
|
|
|
|
// authCookies forges an authenticated session for the given user.
|
|
func (e *testEnv) authCookies(
|
|
t *testing.T,
|
|
userID, username string,
|
|
) []*http.Cookie {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/setup", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
s, err := e.sess.Get(req)
|
|
require.NoError(t, err)
|
|
|
|
e.sess.SetUser(s, userID, username)
|
|
require.NoError(t, e.sess.Save(req, w, s))
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
|
|
return cookies
|
|
}
|
|
|
|
// seedUser creates a user with the given password and returns the
|
|
// stored hash so tests can assert whether it later changed.
|
|
func (e *testEnv) seedUser(
|
|
t *testing.T,
|
|
username, password string,
|
|
) (string, string) {
|
|
t.Helper()
|
|
|
|
hash, err := database.HashPassword(password)
|
|
require.NoError(t, err)
|
|
|
|
user := &database.User{Username: username, Password: hash}
|
|
require.NoError(t, e.db.DB().Create(user).Error)
|
|
|
|
return user.ID, hash
|
|
}
|
|
|
|
// seedWebhook creates a webhook owned by the given user.
|
|
func (e *testEnv) seedWebhook(
|
|
t *testing.T,
|
|
userID string,
|
|
) *database.Webhook {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{UserID: userID, Name: "routed"}
|
|
|
|
require.NoError(
|
|
t,
|
|
e.db.DB().Omit(clause.Associations).Create(wh).Error,
|
|
)
|
|
|
|
return wh
|
|
}
|
|
|
|
// seedEvent records one event with the given body in a webhook's
|
|
// own database.
|
|
func (e *testEnv) seedEvent(
|
|
t *testing.T,
|
|
webhookID, body string,
|
|
) *database.Event {
|
|
t.Helper()
|
|
|
|
webhookDB, err := e.dbMgr.GetDB(webhookID)
|
|
require.NoError(t, err)
|
|
|
|
event := &database.Event{
|
|
WebhookID: webhookID,
|
|
Method: http.MethodPost,
|
|
Body: body,
|
|
ContentType: "application/octet-stream",
|
|
}
|
|
|
|
require.NoError(
|
|
t,
|
|
webhookDB.Omit(clause.Associations).Create(event).Error,
|
|
)
|
|
|
|
return event
|
|
}
|
|
|
|
// storedHash reads the current password hash for a username.
|
|
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
|
t.Helper()
|
|
|
|
var user database.User
|
|
|
|
require.NoError(t,
|
|
e.db.DB().Where("username = ?", username).
|
|
First(&user).Error,
|
|
)
|
|
|
|
return user.Password
|
|
}
|
|
|
|
// --- /s static group ---
|
|
|
|
// TestStaticServesEveryMethod pins what the static mount actually
|
|
// answers. chi's Mount registers the handler for all methods and
|
|
// http.FileServer only special-cases HEAD (by suppressing the body),
|
|
// so a POST or a DELETE to an asset is served the file rather than
|
|
// refused. The README documents this; the test is what keeps the two
|
|
// from drifting.
|
|
func TestStaticServesEveryMethod(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
body, err := static.Static.ReadFile("js/app.js")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, body)
|
|
|
|
for _, method := range []string{
|
|
http.MethodGet,
|
|
http.MethodHead,
|
|
http.MethodPost,
|
|
http.MethodPut,
|
|
http.MethodDelete,
|
|
} {
|
|
t.Run(method, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), method,
|
|
"/s/js/app.js", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code,
|
|
"static mount answers every method")
|
|
|
|
if method == http.MethodHead {
|
|
assert.Empty(t, w.Body.Bytes(),
|
|
"HEAD must not carry a body")
|
|
|
|
return
|
|
}
|
|
|
|
assert.Equal(t, body, w.Body.Bytes(),
|
|
"the asset itself is returned")
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- /pages group ---
|
|
|
|
// TestPagesLogin_OversizeBody_RejectedBeforeCSRF proves the cap runs
|
|
// ahead of gorilla/csrf: the response is a clean 413 and no CSRF
|
|
// cookie was issued, so neither the CSRF middleware nor the login
|
|
// handler ran.
|
|
func TestPagesLogin_OversizeBody_RejectedBeforeCSRF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
form := url.Values{}
|
|
form.Set("username", oversizeValue())
|
|
form.Set("password", "irrelevant")
|
|
|
|
w := env.post("/pages/login", form, nil)
|
|
|
|
assert.Equal(
|
|
t, http.StatusRequestEntityTooLarge, w.Code,
|
|
)
|
|
assert.False(
|
|
t, csrfCookieSet(w),
|
|
"CSRF middleware must not run for an oversized body",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_UnderLimit_NoToken_CSRFRejects is the control for
|
|
// the test above: an identically shaped but under-limit POST does
|
|
// reach gorilla/csrf, which rejects it and issues its cookie. Without
|
|
// this, the missing-cookie assertion above would prove nothing.
|
|
func TestPagesLogin_UnderLimit_NoToken_CSRFRejects(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
form := url.Values{}
|
|
form.Set("username", "someone")
|
|
form.Set("password", "irrelevant")
|
|
|
|
w := env.post("/pages/login", form, nil)
|
|
|
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
assert.True(
|
|
t, csrfCookieSet(w),
|
|
"CSRF middleware should run for an under-limit body",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_UnderLimit_ValidToken_ReachesHandler proves the
|
|
// reorder did not break CSRF token handling: a token harvested from
|
|
// the rendered login form is still accepted and the request lands in
|
|
// the handler.
|
|
func TestPagesLogin_UnderLimit_ValidToken_ReachesHandler(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
token, cookies := env.csrfFrom(t, "/pages/login", nil)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("username", "nosuchuser")
|
|
form.Set("password", "wrongpassword")
|
|
|
|
w := env.post("/pages/login", form, cookies)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
assert.Contains(
|
|
t, w.Body.String(), "Invalid username or password",
|
|
"request should reach the login handler",
|
|
)
|
|
}
|
|
|
|
// TestPagesLogin_CorrectPasswordSurvivesASpentBudget pins the
|
|
// routing half of the fix, which every other login test misses by
|
|
// driving the handler directly: no pre-emptive limiter sits in front
|
|
// of POST /pages/login on the real route tree.
|
|
//
|
|
// A limiter registered there would answer the last request 429
|
|
// however correct its password is, because the wrong passwords
|
|
// before it have already spent the bucket — which is the lockout
|
|
// this endpoint exists to not have. CSRF and the body cap still run,
|
|
// since every request here carries a harvested token.
|
|
func TestPagesLogin_CorrectPasswordSurvivesASpentBudget(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
username = "operator"
|
|
password = "correct-horse-battery-staple"
|
|
)
|
|
|
|
env := newTestEnv(t)
|
|
env.seedUser(t, username, password)
|
|
|
|
submit := func(t *testing.T, pw string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
|
|
token, cookies := env.csrfFrom(t, "/pages/login", nil)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("username", username)
|
|
form.Set("password", pw)
|
|
|
|
return env.post("/pages/login", form, cookies)
|
|
}
|
|
|
|
// Spend the failure budget against this username. The exact
|
|
// limit belongs to the middleware; this waits for the throttle
|
|
// to appear rather than restating it, under a ceiling well
|
|
// above it so a broken limiter fails the test instead of
|
|
// looping.
|
|
const maxAttempts = 20
|
|
|
|
spent := false
|
|
|
|
for range maxAttempts {
|
|
code := submit(t, "wrong").Code
|
|
if code == http.StatusTooManyRequests {
|
|
spent = true
|
|
|
|
break
|
|
}
|
|
|
|
require.Equal(
|
|
t, http.StatusUnauthorized, code,
|
|
"a wrong password must be rejected, not accepted",
|
|
)
|
|
}
|
|
|
|
require.True(
|
|
t, spent,
|
|
"repeated wrong passwords must eventually be throttled",
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusSeeOther, submit(t, password).Code,
|
|
"a correct password must be accepted on the routed "+
|
|
"endpoint even with the failure budget spent: the "+
|
|
"operator has no second administrative path",
|
|
)
|
|
}
|
|
|
|
// --- /user/{username} group ---
|
|
|
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
|
// covers the route that previously had no middleware body cap at
|
|
// all. The request carries a valid session and a valid CSRF token,
|
|
// so the only thing that can stop it is the size cap; the unchanged
|
|
// password hash is the observable proof the handler never ran.
|
|
func TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, originalHash := env.seedUser(t, "pwuser", "oldpassword")
|
|
cookies := env.authCookies(t, userID, "pwuser")
|
|
token, cookies := env.csrfFrom(t, "/user/pwuser/", cookies)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("current_password", "oldpassword")
|
|
form.Set("new_password", oversizeValue())
|
|
form.Set("confirm_password", oversizeValue())
|
|
|
|
w := env.post("/user/pwuser/password", form, cookies)
|
|
|
|
assert.Equal(
|
|
t, http.StatusRequestEntityTooLarge, w.Code,
|
|
)
|
|
assert.Equal(
|
|
t, originalHash, env.storedHash(t, "pwuser"),
|
|
"handler must not run, so the password must be unchanged",
|
|
)
|
|
}
|
|
|
|
// TestPasswordChange_UnderLimit_Succeeds proves that adding the cap
|
|
// to the /user/{username} group did not break the route it guards.
|
|
func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, originalHash := env.seedUser(t, "okuser", "oldpassword")
|
|
cookies := env.authCookies(t, userID, "okuser")
|
|
token, cookies := env.csrfFrom(t, "/user/okuser/", cookies)
|
|
|
|
form := url.Values{}
|
|
form.Set("csrf_token", token)
|
|
form.Set("current_password", "oldpassword")
|
|
form.Set("new_password", "brandnewpassword")
|
|
form.Set("confirm_password", "brandnewpassword")
|
|
|
|
w := env.post("/user/okuser/password", form, cookies)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.NotEqual(
|
|
t, originalHash, env.storedHash(t, "okuser"),
|
|
"an under-limit password change should still apply",
|
|
)
|
|
}
|
|
|
|
// --- /source/{sourceID} group ---
|
|
|
|
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
|
|
// feature the way a user does: render the event log page through
|
|
// the production router, take the download URL out of the markup
|
|
// the template emitted, and fetch that URL through the router
|
|
// again. Nothing here is hand-written, so a typo in either the
|
|
// route pattern or the template href fails this test — the
|
|
// handler-level tests cannot catch that, because they forge
|
|
// their own route context and assert a URL string they wrote
|
|
// themselves.
|
|
func TestSourceLogs_TruncationLinkDownloadsTheBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
userID, _ := env.seedUser(t, "loguser", "somepassword")
|
|
cookies := env.authCookies(t, userID, "loguser")
|
|
|
|
// Comfortably over the event log page's render cap, so the
|
|
// page truncates the body and renders the download link at
|
|
// all. The exact cap is the handlers package's business and
|
|
// is pinned by its own tests; this only needs to exceed it.
|
|
stored := strings.Repeat("Z", 64*1024)
|
|
|
|
wh := env.seedWebhook(t, userID)
|
|
env.seedEvent(t, wh.ID, stored)
|
|
|
|
page := env.get("/source/"+wh.ID+"/logs", cookies)
|
|
require.Equal(t, http.StatusOK, page.Code)
|
|
|
|
link := regexp.MustCompile(
|
|
`href="(/source/[^"]+/body)"`,
|
|
).FindStringSubmatch(page.Body.String())
|
|
require.Len(
|
|
t, link, 2,
|
|
"truncated body should render a download link",
|
|
)
|
|
|
|
w := env.get(html.UnescapeString(link[1]), cookies)
|
|
|
|
require.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"the link the page emits must be a live route",
|
|
)
|
|
assert.Equal(t, stored, w.Body.String())
|
|
assert.Equal(
|
|
t, strconv.Itoa(len(stored)),
|
|
w.Header().Get("Content-Length"),
|
|
)
|
|
assert.Equal(
|
|
t, "application/octet-stream",
|
|
w.Header().Get("Content-Type"),
|
|
)
|
|
assert.Contains(
|
|
t, w.Header().Get("Content-Disposition"), "attachment",
|
|
)
|
|
assert.Equal(
|
|
t, "nosniff", w.Header().Get("X-Content-Type-Options"),
|
|
)
|
|
}
|
|
|
|
// TestSourceLogsBody_OtherUser404s pins that the download route
|
|
// as registered is behind the auth the group provides and the
|
|
// ownership check the handler applies: another logged-in user
|
|
// asking the real router for the same URL gets a 404, and an
|
|
// unauthenticated request never reaches the handler at all.
|
|
func TestSourceLogsBody_OtherUser404s(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
ownerID, _ := env.seedUser(t, "owner", "somepassword")
|
|
wh := env.seedWebhook(t, ownerID)
|
|
|
|
const payload = "OWNERS-PAYLOAD-77c1"
|
|
|
|
evt := env.seedEvent(t, wh.ID, payload)
|
|
path := "/source/" + wh.ID + "/logs/" + evt.ID + "/body"
|
|
|
|
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
|
|
intruder := env.authCookies(t, intruderID, "intruder")
|
|
|
|
w := env.get(path, intruder)
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
assert.NotContains(t, w.Body.String(), payload)
|
|
|
|
anon := env.get(path, nil)
|
|
assert.Equal(t, http.StatusSeeOther, anon.Code)
|
|
assert.Equal(t, "/pages/login", anon.Header().Get("Location"))
|
|
}
|
|
|
|
// metricsConfig is a Config differing from the routing default only
|
|
// in the two /metrics credentials.
|
|
func metricsConfig(
|
|
t *testing.T, username, password string,
|
|
) *config.Config {
|
|
t.Helper()
|
|
|
|
return &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
MetricsUsername: username,
|
|
MetricsPassword: password,
|
|
}
|
|
}
|
|
|
|
// metricsRequest asks the real router for /metrics with the given
|
|
// basic-auth credentials, or with no Authorization header when
|
|
// username is empty.
|
|
func (e *testEnv) metricsRequest(
|
|
username, password string,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, "/metrics", nil,
|
|
)
|
|
|
|
if username != "" {
|
|
req.SetBasicAuth(username, password)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
e.router.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// TestMetricsRouteUnmountedWithoutCredentials pins that with neither
|
|
// credential configured the route does not exist, which is the
|
|
// documented behaviour and the only valid way for /metrics to be
|
|
// absent.
|
|
func TestMetricsRouteUnmountedWithoutCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithConfig(t, metricsConfig(t, "", ""))
|
|
|
|
assert.Equal(
|
|
t, http.StatusNotFound,
|
|
env.metricsRequest("", "").Code,
|
|
)
|
|
}
|
|
|
|
// TestMetricsRouteRequiresCredentials pins that with both credentials
|
|
// configured the route exists and every request that does not carry
|
|
// the configured pair is refused — including the empty password that
|
|
// a half-set configuration used to make sufficient.
|
|
func TestMetricsRouteRequiresCredentials(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithConfig(
|
|
t, metricsConfig(t, metricsUser, metricsAuthValue),
|
|
)
|
|
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
env.metricsRequest("", "").Code,
|
|
"no credentials must not reach the metrics handler",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
env.metricsRequest(metricsUser, "").Code,
|
|
"an empty password must not reach the metrics handler",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusUnauthorized,
|
|
env.metricsRequest(metricsUser, "wrong").Code,
|
|
)
|
|
|
|
ok := env.metricsRequest(metricsUser, metricsAuthValue)
|
|
assert.Equal(t, http.StatusOK, ok.Code)
|
|
assert.Contains(t, ok.Body.String(), "go_goroutines")
|
|
}
|
|
|
|
// TestMetricsRouteUnmountedOnHalfSetConfig pins the defect from
|
|
// https://git.eeqj.de/sneak/webhooker/issues/205 at the routing
|
|
// layer. Config rejects a half-set pair at startup, so this Config
|
|
// cannot be reached from the environment; the assertion is that the
|
|
// route tree does not publish an endpoint accepting an empty
|
|
// password even when handed one anyway, because the mount and the
|
|
// startup log's hasMetricsAuth read the same value.
|
|
func TestMetricsRouteUnmountedOnHalfSetConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
username string
|
|
password string
|
|
}{
|
|
{name: "username only", username: metricsUser},
|
|
{name: "password only", password: metricsAuthValue},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := metricsConfig(t, tc.username, tc.password)
|
|
env := newTestEnvWithConfig(t, cfg)
|
|
|
|
assert.False(t, cfg.MetricsAuthEnabled())
|
|
assert.Equal(
|
|
t, http.StatusNotFound,
|
|
env.metricsRequest(
|
|
tc.username, tc.password,
|
|
).Code,
|
|
)
|
|
})
|
|
}
|
|
}
|