All checks were successful
check / check (push) Successful in 2m52s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 64 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory: a waiter reaches the guard with its form parsed, so it holds up to the 1 MB body cap for the whole wait, and at flood rates an unbounded queue is worth gigabytes against a 128 MB hashing budget. 64 waiters is 64 MB of committed queue memory, shallow enough that two slots drain a full queue inside the five-second deadline; peak commitment is 128 MB of hashing plus about 66 MB of parsed bodies. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
647 lines
17 KiB
Go
647 lines
17 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"
|
|
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
|
|
var (
|
|
log *logger.Logger
|
|
cfg *config.Config
|
|
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 &config.Config{
|
|
DataDir: t.TempDir(),
|
|
Environment: config.EnvironmentDev,
|
|
}
|
|
},
|
|
database.New,
|
|
database.NewWebhookDBManager,
|
|
healthcheck.New,
|
|
session.New,
|
|
func() delivery.Notifier { return &noopNotifier{} },
|
|
func() delivery.WebhookEvictor { return &noopEvictor{} },
|
|
middleware.New,
|
|
handlers.New,
|
|
),
|
|
fx.Populate(&log, &cfg, &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,
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
}
|