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
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user