upaas/internal/handlers/handlers_test.go
clawbot 867cdf01ab fix: add ownership verification on env var, label, volume, and port deletion
Verify that the resource's AppID matches the URL path app ID before
allowing deletion. Without this check, any authenticated user could
delete resources belonging to any app by providing the target resource's
ID in the URL regardless of the app ID in the path (IDOR vulnerability).

Closes #19
2026-02-15 21:02:46 -08:00

725 lines
19 KiB
Go

package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
)
type testContext struct {
handlers *handlers.Handlers
database *database.Database
authSvc *auth.Service
appSvc *app.Service
}
func createTestConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Port: 8080,
DataDir: t.TempDir(),
SessionSecret: "test-secret-key-at-least-32-characters-long",
}
}
func createCoreServices(
t *testing.T,
cfg *config.Config,
) (*globals.Globals, *logger.Logger, *database.Database, *healthcheck.Healthcheck) {
t.Helper()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalInstance, globErr := globals.New(fx.Lifecycle(nil))
require.NoError(t, globErr)
logInstance, logErr := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalInstance},
)
require.NoError(t, logErr)
dbInstance, dbErr := database.New(fx.Lifecycle(nil), database.Params{
Logger: logInstance,
Config: cfg,
})
require.NoError(t, dbErr)
hcInstance, hcErr := healthcheck.New(
fx.Lifecycle(nil),
healthcheck.Params{
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
},
)
require.NoError(t, hcErr)
return globalInstance, logInstance, dbInstance, hcInstance
}
func createAppServices(
t *testing.T,
logInstance *logger.Logger,
dbInstance *database.Database,
cfg *config.Config,
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client) {
t.Helper()
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: logInstance,
Config: cfg,
Database: dbInstance,
})
require.NoError(t, authErr)
appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: logInstance,
Database: dbInstance,
})
require.NoError(t, appErr)
dockerClient, dockerErr := docker.New(fx.Lifecycle(nil), docker.Params{
Logger: logInstance,
Config: cfg,
})
require.NoError(t, dockerErr)
notifySvc, notifyErr := notify.New(fx.Lifecycle(nil), notify.ServiceParams{
Logger: logInstance,
})
require.NoError(t, notifyErr)
deploySvc, deployErr := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: logInstance,
Config: cfg,
Database: dbInstance,
Docker: dockerClient,
Notify: notifySvc,
})
require.NoError(t, deployErr)
webhookSvc, webhookErr := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
Logger: logInstance,
Database: dbInstance,
Deploy: deploySvc,
})
require.NoError(t, webhookErr)
return authSvc, appSvc, deploySvc, webhookSvc, dockerClient
}
func setupTestHandlers(t *testing.T) *testContext {
t.Helper()
cfg := createTestConfig(t)
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg)
authSvc, appSvc, deploySvc, webhookSvc, dockerClient := createAppServices(
t,
logInstance,
dbInstance,
cfg,
)
handlersInstance, handlerErr := handlers.New(
fx.Lifecycle(nil),
handlers.Params{
Logger: logInstance,
Globals: globalInstance,
Database: dbInstance,
Healthcheck: hcInstance,
Auth: authSvc,
App: appSvc,
Deploy: deploySvc,
Webhook: webhookSvc,
Docker: dockerClient,
},
)
require.NoError(t, handlerErr)
return &testContext{
handlers: handlersInstance,
database: dbInstance,
authSvc: authSvc,
appSvc: appSvc,
}
}
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
t.Run("returns health check response", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(
http.MethodGet,
"/.well-known/healthcheck.json",
nil,
)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleHealthCheck()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
assert.Contains(t, recorder.Body.String(), "status")
assert.Contains(t, recorder.Body.String(), "ok")
})
}
func TestHandleSetupGET(t *testing.T) {
t.Parallel()
t.Run("renders setup page", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "setup")
})
}
func createSetupFormRequest(
username, password, confirm string,
) *http.Request {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
form.Set("password_confirm", confirm)
request := httptest.NewRequest(
http.MethodPost,
"/setup",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return request
}
func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "password123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, "/", recorder.Header().Get("Location"))
}
func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("", "password123", "password123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "required")
}
func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "short", "short")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "8 characters")
}
func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "different123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "do not match")
}
func TestHandleLoginGET(t *testing.T) {
t.Parallel()
t.Run("renders login page", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "login")
})
}
func createLoginFormRequest(username, password string) *http.Request {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return request
}
func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create user first
_, createErr := testCtx.authSvc.CreateUser(
context.Background(),
"testuser",
"testpass123",
)
require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "testpass123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, "/", recorder.Header().Get("Location"))
}
func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create user first
_, createErr := testCtx.authSvc.CreateUser(
context.Background(),
"testuser",
"testpass123",
)
require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "wrongpassword")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Invalid")
}
func TestHandleDashboard(t *testing.T) {
t.Parallel()
t.Run("renders dashboard with app list", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Applications")
})
}
func TestHandleAppNew(t *testing.T) {
t.Parallel()
t.Run("renders new app form", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppNew()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
}
// addChiURLParams adds chi URL parameters to a request for testing.
func addChiURLParams(
request *http.Request,
params map[string]string,
) *http.Request {
routeContext := chi.NewRouteContext()
for key, value := range params {
routeContext.URLParams.Add(key, value)
}
return request.WithContext(
context.WithValue(request.Context(), chi.RouteCtxKey, routeContext),
)
}
// createTestApp creates an app using the app service and returns it.
func createTestApp(
t *testing.T,
tc *testContext,
name string,
) *models.App {
t.Helper()
createdApp, err := tc.appSvc.CreateApp(
context.Background(),
app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: "main",
},
)
require.NoError(t, err)
return createdApp
}
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// via another app's URL path returns 404 (IDOR prevention).
func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app first
createdApp, createErr := testCtx.appSvc.CreateApp(
context.Background(),
app.CreateAppInput{
Name: "oversize-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
},
)
require.NoError(t, createErr)
// Create a body larger than 1MB - it should be silently truncated
// and the webhook should still process (or fail gracefully on parse)
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
request := httptest.NewRequest(
http.MethodPost,
"/webhook/"+createdApp.WebhookSecret,
strings.NewReader(largePayload),
)
request = addChiURLParams(
request,
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleWebhook()
handler.ServeHTTP(recorder, request)
// Should still return OK (payload is truncated and fails JSON parse,
// but webhook service handles invalid JSON gracefully)
assert.Equal(t, http.StatusOK, recorder.Code)
}
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteEnvVarOwnershipVerification(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
app1 := createTestApp(t, testCtx, "envvar-owner-app")
app2 := createTestApp(t, testCtx, "envvar-other-app")
// Create env var belonging to app1
envVar := models.NewEnvVar(testCtx.database)
envVar.AppID = app1.ID
envVar.Key = "SECRET"
envVar.Value = "hunter2"
require.NoError(t, envVar.Save(context.Background()))
// Try to delete app1's env var using app2's URL path
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+app2.ID+"/env/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
)
request = addChiURLParams(request, map[string]string{
"id": app2.ID,
"envID": strconv.FormatInt(envVar.ID, 10),
})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleEnvVarDelete()
handler.ServeHTTP(recorder, request)
// Should return 404 because the env var doesn't belong to app2
assert.Equal(t, http.StatusNotFound, recorder.Code)
// Verify the env var was NOT deleted
found, err := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
require.NoError(t, err)
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
}
// TestDeleteLabelOwnershipVerification tests that deleting a label
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteLabelOwnershipVerification(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
app1 := createTestApp(t, testCtx, "label-owner-app")
app2 := createTestApp(t, testCtx, "label-other-app")
// Create label belonging to app1
label := models.NewLabel(testCtx.database)
label.AppID = app1.ID
label.Key = "traefik.enable"
label.Value = "true"
require.NoError(t, label.Save(context.Background()))
// Try to delete app1's label using app2's URL path
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+app2.ID+"/labels/"+strconv.FormatInt(label.ID, 10)+"/delete",
nil,
)
request = addChiURLParams(request, map[string]string{
"id": app2.ID,
"labelID": strconv.FormatInt(label.ID, 10),
})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLabelDelete()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
// Verify the label was NOT deleted
found, err := models.FindLabel(context.Background(), testCtx.database, label.ID)
require.NoError(t, err)
assert.NotNil(t, found, "label should still exist after IDOR attempt")
}
// TestDeleteVolumeOwnershipVerification tests that deleting a volume
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteVolumeOwnershipVerification(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
app1 := createTestApp(t, testCtx, "volume-owner-app")
app2 := createTestApp(t, testCtx, "volume-other-app")
// Create volume belonging to app1
volume := models.NewVolume(testCtx.database)
volume.AppID = app1.ID
volume.HostPath = "/data/app1"
volume.ContainerPath = "/app/data"
volume.ReadOnly = false
require.NoError(t, volume.Save(context.Background()))
// Try to delete app1's volume using app2's URL path
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
nil,
)
request = addChiURLParams(request, map[string]string{
"id": app2.ID,
"volumeID": strconv.FormatInt(volume.ID, 10),
})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleVolumeDelete()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
// Verify the volume was NOT deleted
found, err := models.FindVolume(context.Background(), testCtx.database, volume.ID)
require.NoError(t, err)
assert.NotNil(t, found, "volume should still exist after IDOR attempt")
}
// TestDeletePortOwnershipVerification tests that deleting a port
// via another app's URL path returns 404 (IDOR prevention).
func TestDeletePortOwnershipVerification(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
app1 := createTestApp(t, testCtx, "port-owner-app")
app2 := createTestApp(t, testCtx, "port-other-app")
// Create port belonging to app1
port := models.NewPort(testCtx.database)
port.AppID = app1.ID
port.HostPort = 8080
port.ContainerPort = 80
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
// Try to delete app1's port using app2's URL path
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
nil,
)
request = addChiURLParams(request, map[string]string{
"id": app2.ID,
"portID": strconv.FormatInt(port.ID, 10),
})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandlePortDelete()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
// Verify the port was NOT deleted
found, err := models.FindPort(context.Background(), testCtx.database, port.ID)
require.NoError(t, err)
assert.NotNil(t, found, "port should still exist after IDOR attempt")
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
webhookURL := "/webhook/unknown-secret"
payload := `{"ref": "refs/heads/main"}`
request := httptest.NewRequest(
http.MethodPost,
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleWebhook()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app first
createdApp, createErr := testCtx.appSvc.CreateApp(
context.Background(),
app.CreateAppInput{
Name: "webhook-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
},
)
require.NoError(t, createErr)
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
webhookURL := "/webhook/" + createdApp.WebhookSecret
request := httptest.NewRequest(
http.MethodPost,
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(
request,
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleWebhook()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
// Allow async deployment goroutine to complete before test cleanup.
// The deployment will fail quickly (docker not connected) but we need
// to wait for it to finish to avoid temp directory cleanup race.
time.Sleep(100 * time.Millisecond)
}