From 0f3e99f7cccca5b474f76664dfb05e204372f995 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 15 Feb 2026 20:52:19 -0800 Subject: [PATCH] test: add IDOR tests for resource deletion ownership verification Tests demonstrate that env vars, labels, volumes, and ports can be deleted via another app's URL path without ownership checks. All 4 tests fail, confirming the vulnerability described in #19. --- internal/handlers/handlers_test.go | 187 +++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index adec558..047b126 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -14,6 +15,8 @@ import ( "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" @@ -426,6 +429,190 @@ func addChiURLParams( ) } +// 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 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()