fix: resolve all 22 gosec G710 open-redirect findings (closes #176) #186

Closed
clawbot wants to merge 2 commits from fix-gosec-g710 into main
8 changed files with 168 additions and 103 deletions

30
TODO.md
View File

@@ -10,18 +10,26 @@
# Status # Status
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. Policy 1.0+. Tagged 1.0.0 on 2026-02-26. Policy violation: main currently
violation: main currently fails make check (91 lint issues), so the tree fails make check under golangci-lint >= 2.12 (25 lint issues remaining:
is out of compliance until fixed. 1 gosec G703, 24 goconst), so the tree is out of compliance until
fixed. CI (Dockerfile lint stage, pinned golangci-lint v2.10.1) is
green; the pin bump is tracked in issue #179. The road to release
1.1.0 is tracked in Gitea issues #175-#185 (milestone 1.1.0).
# Next Step # Next Step
Fix the 47 noctx lint findings (HTTP requests without context) in one Fix the gosec G703 path traversal finding in the deploy log download
commit and confirm the count drops under make check. This is the largest handler (issue #177): canonicalize and containment-check the log path
of the three lint classes blocking a green main. before http.ServeFile, with a traversal-rejection test.
# Completed Steps # Completed Steps
- 2026-08-07: Fixed all 22 gosec G710 open-redirect findings: app
redirects go through a redirectToApp helper that ULID-validates the
app ID (#176).
- 2026-08-07: Fixed all 47 noctx lint findings: tests now use
httptest.NewRequestWithContext with t.Context() (#175).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-03-11: Monolithic env var editing with bulk save (#158). - 2026-03-11: Monolithic env var editing with bulk save (#158).
@@ -45,11 +53,13 @@ of the three lint classes blocking a green main.
# Future Steps # Future Steps
- Get main green (compliance, ordered): - Get main green (compliance, ordered):
- Fix 47 noctx findings (Next Step). - Fix 1 gosec G703 finding (Next Step, #177).
- Fix 23 gosec findings. - Fix 24 goconst findings (#178).
- Fix 21 goconst findings. - Bump Dockerfile golangci-lint pin to v2.12.x (#179).
- Run make check clean on main and keep it green; main must always - Run make check clean on main and keep it green; main must always
pass. pass.
- Confirm .gitea/workflows/check.yml gates merges on make check so main - Confirm .gitea/workflows/check.yml gates merges on make check so main
cannot regress. cannot regress (#180).
- Deploy to fsn1app1 and verify end-to-end (#181), then tag 1.1.0
(#182).
- Resume feature work only after main is green. - Resume feature work only after main is green.

View File

@@ -47,7 +47,12 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
r := apiRouter(tc) r := apiRouter(tc)
loginBody := `{"username":"admin","password":"password123"}` loginBody := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody)) req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(loginBody),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -70,7 +75,7 @@ func apiGet(
) *httptest.ResponseRecorder { ) *httptest.ResponseRecorder {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
for _, c := range cookies { for _, c := range cookies {
req.AddCookie(c) req.AddCookie(c)
@@ -95,7 +100,12 @@ func TestAPILoginSuccess(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
body := `{"username":"admin","password":"password123"}` body := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -122,7 +132,12 @@ func TestAPILoginInvalidCredentials(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
body := `{"username":"admin","password":"wrong"}` body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -139,7 +154,12 @@ func TestAPILoginMissingFields(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
body := `{"username":"","password":""}` body := `{"username":"","password":""}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body)) req := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost,
"/api/v1/login",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -155,7 +175,9 @@ func TestAPIRejectsUnauthenticated(t *testing.T) {
r := apiRouter(tc) r := apiRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil) req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/api/v1/apps", nil,
)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
r.ServeHTTP(rr, req) r.ServeHTTP(rr, req)

View File

@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -14,6 +15,7 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
@@ -27,6 +29,27 @@ const (
deploymentsHistoryLimit = 50 deploymentsHistoryLimit = 50
) )
// redirectToApp issues a See Other redirect to the detail page of the
// given app, plus an optional suffix (a sub-path like "/deployments"
// or a query string like "?success=updated"). App IDs are ULIDs: the
// ID is parsed and re-serialized so the redirect target never
// contains unvalidated request input; an invalid ID yields 404.
func redirectToApp(
writer http.ResponseWriter,
request *http.Request,
appID, suffix string,
) {
id, parseErr := ulid.ParseStrict(appID)
if parseErr != nil {
http.NotFound(writer, request)
return
}
target := "/apps/" + url.PathEscape(id.String()) + suffix
http.Redirect(writer, request, target, http.StatusSeeOther)
}
// HandleAppNew returns the new app form handler. // HandleAppNew returns the new app form handler.
func (h *Handlers) HandleAppNew() http.HandlerFunc { func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@@ -119,7 +142,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther) redirectToApp(writer, request, createdApp.ID, "")
} }
} }
@@ -285,8 +308,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
redirectURL := "/apps/" + application.ID + "?success=updated" redirectToApp(writer, request, application.ID, "?success=updated")
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
} }
} }
@@ -371,12 +393,7 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
} }
}(deployCtx, application) }(deployCtx, application)
http.Redirect( redirectToApp(writer, request, application.ID, "/deployments")
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
} }
} }
@@ -397,12 +414,7 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
h.log.Info("deployment cancelled by user", "app", application.Name) h.log.Info("deployment cancelled by user", "app", application.Name)
} }
http.Redirect( redirectToApp(writer, request, application.ID, "")
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
} }
} }
@@ -421,12 +433,12 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
rollbackErr := h.deploy.Rollback(request.Context(), application) rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil { if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name) h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
return return
} }
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther) redirectToApp(writer, request, application.ID, "?success=rolledback")
} }
} }
@@ -799,7 +811,7 @@ func (h *Handlers) handleContainerAction(
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID) containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil { if containerErr != nil || containerInfo == nil {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
return return
} }
@@ -832,7 +844,7 @@ func (h *Handlers) handleContainerAction(
"action", action, "app", application.Name, "container", containerID) "action", action, "app", application.Name, "container", containerID)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
} }
// HandleAppRestart handles restarting an app's container. // HandleAppRestart handles restarting an app's container.
@@ -886,7 +898,7 @@ func (h *Handlers) addKeyValueToApp(
value := request.FormValue("value") value := request.FormValue("value")
if key == "" || value == "" { if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
return return
} }
@@ -896,7 +908,7 @@ func (h *Handlers) addKeyValueToApp(
h.log.Error("failed to add key-value pair", "error", saveErr) h.log.Error("failed to add key-value pair", "error", saveErr)
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
} }
// envPairJSON represents a key-value pair in the JSON request body. // envPairJSON represents a key-value pair in the JSON request body.
@@ -1031,7 +1043,7 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
h.log.Error("failed to delete label", "error", deleteErr) h.log.Error("failed to delete label", "error", deleteErr)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
} }
} }
@@ -1059,12 +1071,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1" readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" { if hostPath == "" || containerPath == "" {
http.Redirect( redirectToApp(writer, request, application.ID, "")
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
return return
} }
@@ -1072,7 +1079,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath) pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil { if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr) h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
return return
} }
@@ -1088,7 +1095,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
h.log.Error("failed to add volume", "error", saveErr) h.log.Error("failed to add volume", "error", saveErr)
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
} }
} }
@@ -1117,7 +1124,7 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
h.log.Error("failed to delete volume", "error", deleteErr) h.log.Error("failed to delete volume", "error", deleteErr)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
} }
} }
@@ -1145,7 +1152,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
request.FormValue("container_port"), request.FormValue("container_port"),
) )
if !valid { if !valid {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
return return
} }
@@ -1166,7 +1173,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
h.log.Error("failed to save port", "error", saveErr) h.log.Error("failed to save port", "error", saveErr)
} }
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) redirectToApp(writer, request, application.ID, "")
} }
} }
@@ -1212,7 +1219,7 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
h.log.Error("failed to delete port", "error", deleteErr) h.log.Error("failed to delete port", "error", deleteErr)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
} }
} }
@@ -1274,7 +1281,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
value := request.FormValue("value") value := request.FormValue("value")
if key == "" || value == "" { if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
return return
} }
@@ -1287,7 +1294,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
h.log.Error("failed to update label", "error", saveErr) h.log.Error("failed to update label", "error", saveErr)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
} }
} }
@@ -1323,7 +1330,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1" readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" { if hostPath == "" || containerPath == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
return return
} }
@@ -1331,7 +1338,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath) pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil { if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr) h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
return return
} }
@@ -1345,7 +1352,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
h.log.Error("failed to update volume", "error", saveErr) h.log.Error("failed to update volume", "error", saveErr)
} }
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) redirectToApp(writer, request, appID, "")
} }
} }

View File

@@ -193,7 +193,8 @@ func TestHandleHealthCheck(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet, http.MethodGet,
"/.well-known/healthcheck.json", "/.well-known/healthcheck.json",
nil, nil,
@@ -218,7 +219,7 @@ func TestHandleSetupGET(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/setup", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET() handler := testCtx.handlers.HandleSetupGET()
@@ -230,14 +231,18 @@ func TestHandleSetupGET(t *testing.T) {
} }
func createSetupFormRequest( func createSetupFormRequest(
t *testing.T,
username, password, confirm string, username, password, confirm string,
) *http.Request { ) *http.Request {
t.Helper()
form := url.Values{} form := url.Values{}
form.Set("username", username) form.Set("username", username)
form.Set("password", password) form.Set("password", password)
form.Set("password_confirm", confirm) form.Set("password_confirm", confirm)
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/setup", "/setup",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -252,7 +257,7 @@ func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "password123") request := createSetupFormRequest(t, "admin", "password123", "password123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -267,7 +272,7 @@ func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest("", "password123", "password123") request := createSetupFormRequest(t, "", "password123", "password123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -282,7 +287,7 @@ func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "short", "short") request := createSetupFormRequest(t, "admin", "short", "short")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -297,7 +302,7 @@ func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "different123") request := createSetupFormRequest(t, "admin", "password123", "different123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST() handler := testCtx.handlers.HandleSetupPOST()
@@ -315,7 +320,7 @@ func TestHandleLoginGET(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET() handler := testCtx.handlers.HandleLoginGET()
@@ -326,12 +331,15 @@ func TestHandleLoginGET(t *testing.T) {
}) })
} }
func createLoginFormRequest(username, password string) *http.Request { func createLoginFormRequest(t *testing.T, username, password string) *http.Request {
t.Helper()
form := url.Values{} form := url.Values{}
form.Set("username", username) form.Set("username", username)
form.Set("password", password) form.Set("password", password)
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/login", "/login",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -354,7 +362,7 @@ func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
) )
require.NoError(t, createErr) require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "testpass123") request := createLoginFormRequest(t, "testuser", "testpass123")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST() handler := testCtx.handlers.HandleLoginPOST()
@@ -377,7 +385,7 @@ func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
) )
require.NoError(t, createErr) require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "wrongpassword") request := createLoginFormRequest(t, "testuser", "wrongpassword")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST() handler := testCtx.handlers.HandleLoginPOST()
@@ -395,7 +403,7 @@ func TestHandleDashboard(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -413,7 +421,7 @@ func TestHandleDashboard(t *testing.T) {
// Create an app so the template iterates over AppStats and hits .CSRFField // Create an app so the template iterates over AppStats and hits .CSRFField
createTestApp(t, testCtx, "csrf-test-app") createTestApp(t, testCtx, "csrf-test-app")
request := httptest.NewRequest(http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -433,7 +441,9 @@ func TestHandleAppNew(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil) request := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/apps/new", nil,
)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppNew() handler := testCtx.handlers.HandleAppNew()
@@ -501,7 +511,8 @@ func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
// Create a body larger than 1MB - it should be silently truncated // Create a body larger than 1MB - it should be silently truncated
// and the webhook should still process (or fail gracefully on parse) // and the webhook should still process (or fail gracefully on parse)
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/webhook/"+createdApp.WebhookSecret, "/webhook/"+createdApp.WebhookSecret,
strings.NewReader(largePayload), strings.NewReader(largePayload),
@@ -544,7 +555,8 @@ func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
resourceID := cfg.createFn(t, testCtx, app1) resourceID := cfg.createFn(t, testCtx, app1)
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
cfg.deletePath(app2.ID, resourceID), cfg.deletePath(app2.ID, resourceID),
nil, nil,
@@ -583,7 +595,8 @@ func TestHandleEnvVarSaveBulk(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env",
strings.NewReader(body), strings.NewReader(body),
@@ -625,7 +638,8 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/nonexistent-id/env", "/apps/nonexistent-id/env",
strings.NewReader(body), strings.NewReader(body),
@@ -651,7 +665,8 @@ func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env",
strings.NewReader(body), strings.NewReader(body),
@@ -678,7 +693,8 @@ func TestHandleEnvVarSaveDuplicateKeyRejected(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env",
strings.NewReader(body), strings.NewReader(body),
@@ -716,7 +732,8 @@ func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+appA.ID+"/env", "/apps/"+appA.ID+"/env",
strings.NewReader(body), strings.NewReader(body),
@@ -779,7 +796,8 @@ func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env",
strings.NewReader(sb.String()), strings.NewReader(sb.String()),
@@ -848,7 +866,8 @@ func TestDeleteVolumeOwnershipVerification(t *testing.T) {
require.NoError(t, volume.Save(context.Background())) require.NoError(t, volume.Save(context.Background()))
// Try to delete app1's volume using app2's URL path // Try to delete app1's volume using app2's URL path
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete", "/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
nil, nil,
@@ -889,7 +908,8 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
require.NoError(t, port.Save(context.Background())) require.NoError(t, port.Save(context.Background()))
// Try to delete app1's port using app2's URL path // Try to delete app1's port using app2's URL path
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete", "/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
nil, nil,
@@ -930,7 +950,8 @@ func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/apps/"+createdApp.ID+"/env",
strings.NewReader("[]"), strings.NewReader("[]"),
@@ -979,7 +1000,8 @@ func TestHandleVolumeAddValidatesPaths(t *testing.T) {
form.Set("host_path", tt.hostPath) form.Set("host_path", tt.hostPath)
form.Set("container_path", tt.containerPath) form.Set("container_path", tt.containerPath)
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/volumes", "/apps/"+createdApp.ID+"/volumes",
strings.NewReader(form.Encode()), strings.NewReader(form.Encode()),
@@ -1038,7 +1060,7 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req) wrapped.ServeHTTP(rr, req)
@@ -1051,7 +1073,7 @@ func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Run("non-exempt redirects", func(t *testing.T) { t.Run("non-exempt redirects", func(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req) wrapped.ServeHTTP(rr, req)
@@ -1067,7 +1089,8 @@ func TestHandleCancelDeployRedirects(t *testing.T) {
createdApp := createTestApp(t, testCtx, "cancel-deploy-app") createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/deployments/cancel", "/apps/"+createdApp.ID+"/deployments/cancel",
nil, nil,
@@ -1087,7 +1110,8 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
"/apps/nonexistent/deployments/cancel", "/apps/nonexistent/deployments/cancel",
nil, nil,
@@ -1108,7 +1132,8 @@ func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
webhookURL := "/webhook/unknown-secret" webhookURL := "/webhook/unknown-secret"
payload := `{"ref": "refs/heads/main"}` payload := `{"ref": "refs/heads/main"}`
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
webhookURL, webhookURL,
strings.NewReader(payload), strings.NewReader(payload),
@@ -1143,7 +1168,8 @@ func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
payload := `{"ref": "refs/heads/main", "after": "abc123"}` payload := `{"ref": "refs/heads/main", "after": "abc123"}`
webhookURL := "/webhook/" + createdApp.WebhookSecret webhookURL := "/webhook/" + createdApp.WebhookSecret
request := httptest.NewRequest( request := httptest.NewRequestWithContext(
t.Context(),
http.MethodPost, http.MethodPost,
webhookURL, webhookURL,
strings.NewReader(payload), strings.NewReader(payload),

View File

@@ -16,7 +16,7 @@ func TestRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
// The setup page is simple and has no DB dependencies // The setup page is simple and has no DB dependencies
request := httptest.NewRequest(http.MethodGet, "/setup", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET() handler := testCtx.handlers.HandleSetupGET()
@@ -39,7 +39,7 @@ func TestDashboardRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard() handler := testCtx.handlers.HandleDashboard()
@@ -59,7 +59,7 @@ func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET() handler := testCtx.handlers.HandleLoginGET()

View File

@@ -32,7 +32,7 @@ func TestCORS_NoOriginsConfigured_NoCORSHeaders(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com") req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -50,7 +50,7 @@ func TestCORS_OriginsConfigured_AllowsMatchingOrigin(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://app.example.com") req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -70,7 +70,7 @@ func TestCORS_OriginsConfigured_RejectsNonMatchingOrigin(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.com") req.Header.Set("Origin", "https://evil.com")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -36,7 +36,7 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
// First 5 requests should succeed (burst) // First 5 requests should succeed (burst)
for i := range 5 { for i := range 5 {
req := httptest.NewRequest(http.MethodPost, "/login", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345" req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
@@ -44,7 +44,7 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
} }
// 6th request should be rate limited // 6th request should be rate limited
req := httptest.NewRequest(http.MethodPost, "/login", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345" req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
@@ -63,21 +63,21 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
// Exhaust IP1's budget // Exhaust IP1's budget
for range 5 { for range 5 {
req := httptest.NewRequest(http.MethodPost, "/login", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234" req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
} }
// IP1 should be blocked // IP1 should be blocked
req := httptest.NewRequest(http.MethodPost, "/login", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234" req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code) assert.Equal(t, http.StatusTooManyRequests, rec.Code)
// IP2 should still work // IP2 should still work
req2 := httptest.NewRequest(http.MethodPost, "/login", nil) req2 := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req2.RemoteAddr = "10.0.0.2:1234" req2.RemoteAddr = "10.0.0.2:1234"
rec2 := httptest.NewRecorder() rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2) handler.ServeHTTP(rec2, req2)
@@ -96,13 +96,13 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
// Exhaust burst // Exhaust burst
for range 5 { for range 5 {
req := httptest.NewRequest(http.MethodPost, "/login", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555" req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)
} }
req := httptest.NewRequest(http.MethodPost, "/login", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555" req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) handler.ServeHTTP(rec, req)

View File

@@ -121,7 +121,7 @@ func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
require.NoError(t, err) require.NoError(t, err)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
err = svc.CreateSession(recorder, request, user) err = svc.CreateSession(recorder, request, user)
require.NoError(t, err) require.NoError(t, err)
@@ -380,7 +380,7 @@ func TestDestroySessionMaxAge(testingT *testing.T) {
defer cleanup() defer cleanup()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil) request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
err := svc.DestroySession(recorder, request) err := svc.DestroySession(recorder, request)
require.NoError(t, err) require.NoError(t, err)