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