Compare commits

...

2 Commits

Author SHA1 Message Date
13d5467177 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 20:52:59 -08:00
0f3e99f7cc 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.
2026-02-15 20:52:19 -08:00
2 changed files with 191 additions and 4 deletions

View File

@ -801,7 +801,7 @@ func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
} }
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID) envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil { if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@ -848,7 +848,7 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
} }
label, findErr := models.FindLabel(request.Context(), h.db, labelID) label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil { if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@ -926,7 +926,7 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
} }
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID) volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil { if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@ -1016,7 +1016,7 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
} }
port, findErr := models.FindPort(request.Context(), h.db, portID) port, findErr := models.FindPort(request.Context(), h.db, portID)
if findErr != nil || port == nil { if findErr != nil || port == nil || port.AppID != appID {
http.NotFound(writer, request) http.NotFound(writer, request)
return return

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -14,6 +15,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "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) { func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel() t.Parallel()