fix(ads): verify a message belongs to its campaign before edit/delete
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s

DeleteMessage/EditMessage acted on the message id without checking it belonged
to the campaign id in the URL. A mismatched pair could edit an unrelated
campaign's message or — worse — delete the default campaign's last message via
another campaign's URL, bypassing the "default keeps >=1 message" guard. Both
now load the campaign and return ErrNotFound unless it owns the message
(MoveMessage already did). Operator-only, but a real business-logic bypass.
Regression test: TestBannerMessageOwnership.
This commit is contained in:
Ilia Denisov
2026-06-15 23:05:41 +02:00
parent 0946a3f66c
commit 00a33c227b
3 changed files with 75 additions and 7 deletions
@@ -144,6 +144,49 @@ func TestBannerConsoleCRUD(t *testing.T) {
}
}
// TestBannerMessageOwnership guards the campaign/message pairing: a message may
// only be edited or deleted through the URL of the campaign it belongs to. This
// closes a default-protection bypass — deleting the default's last message via an
// unrelated campaign's URL must be refused.
func TestBannerMessageOwnership(t *testing.T) {
ctx := context.Background()
srv, adsSvc := bannerServer(t)
h := srv.Handler()
base := "http://admin.test/_gm"
const origin = "http://admin.test"
def := defaultCampaign(t, adsSvc)
if len(def.Messages) == 0 {
t.Fatal("default campaign has no seeded message")
}
defMsg := def.Messages[0].ID
// A separate timed campaign whose URL we will (mis)use.
otherID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{Name: "Own-" + uuid.NewString()[:8], Weight: 10, Enabled: true})
if err != nil {
t.Fatalf("create campaign: %v", err)
}
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, otherID) })
// Deleting the default's message through the other campaign's URL is refused,
// and the default keeps its message (the bypass is closed).
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not found") {
t.Fatalf("cross-campaign delete = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found"))
}
// Editing it through the wrong campaign is refused too.
if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String(), "body_en=Hijacked&body_ru=Взлом", origin); code != http.StatusOK || !strings.Contains(body, "Not found") {
t.Fatalf("cross-campaign edit = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found"))
}
// The default's message is intact.
again := defaultCampaign(t, adsSvc)
if len(again.Messages) != len(def.Messages) {
t.Fatalf("default messages = %d, want %d (unchanged)", len(again.Messages), len(def.Messages))
}
if again.Messages[0].BodyEn != def.Messages[0].BodyEn {
t.Fatalf("default message body changed: %q -> %q", def.Messages[0].BodyEn, again.Messages[0].BodyEn)
}
}
// profileBanner is the banner block of the profile.get JSON response.
type profileBanner struct {
Banner *struct {