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
+31 -6
View File
@@ -139,14 +139,23 @@ func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn,
return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
}
// EditMessage validates and updates a message's bilingual bodies (its position is
// unchanged).
func (s *Service) EditMessage(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error {
// EditMessage validates and updates the bilingual bodies of a message that
// belongs to campaignID (its position is unchanged). It returns ErrNotFound when
// the message is not part of that campaign, so a mismatched campaign/message pair
// never edits an unrelated campaign's message.
func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error {
en, ru, err := validBodies(bodyEn, bodyRu)
if err != nil {
return err
}
return s.store.UpdateMessageBodies(ctx, id, en, ru)
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
if !campaignOwnsMessage(c, messageID) {
return ErrNotFound
}
return s.store.UpdateMessageBodies(ctx, messageID, en, ru)
}
// MoveMessage reorders a message within its campaign by swapping its position
@@ -178,19 +187,35 @@ func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UU
return s.store.SetMessagePosition(ctx, b.ID, idx)
}
// DeleteMessage removes a message, refusing to remove the default campaign's last
// remaining message (the default must always have a creative to show).
// DeleteMessage removes a message that belongs to campaignID, refusing to remove
// the default campaign's last remaining message (the default must always have a
// creative to show). It returns ErrNotFound when the message is not part of that
// campaign — so a mismatched campaign/message pair can neither delete an
// unrelated campaign's message nor bypass the default's last-message guard.
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
c, err := s.store.Campaign(ctx, campaignID)
if err != nil {
return err
}
if !campaignOwnsMessage(c, messageID) {
return ErrNotFound
}
if c.IsDefault && len(c.Messages) <= 1 {
return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
}
return s.store.DeleteMessage(ctx, messageID)
}
// campaignOwnsMessage reports whether messageID is one of the campaign's messages.
func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool {
for _, m := range c.Messages {
if m.ID == messageID {
return true
}
}
return false
}
// Settings returns the global display timings.
func (s *Service) Settings(ctx context.Context) (Timings, error) {
return s.store.Settings(ctx)