fix: serialise redirect writes so concurrent saves cannot store a loop (WR-10)

The save-time simulation read the rules at READ COMMITTED without a lock,
so A -> B and B -> A written concurrently both passed. CheckRedirect,
AddressChanged and every batch now take a transaction-level advisory lock
before they read the rules.
This commit is contained in:
2026-10-27 15:29:08 +01:00
parent cb614cb1fb
commit c2f136ad7d
5 changed files with 69 additions and 1 deletions

View File

@@ -96,6 +96,9 @@ func (s *Service) applyAddressRedirects(ctx context.Context, tx *gorm.DB, res *A
return nil
}
if err := lockRedirectWrites(tx.WithContext(ctx)); err != nil {
return err
}
rules, err := LoadRedirects(tx)
if err != nil {
return err

View File

@@ -241,6 +241,11 @@ func (s *Service) runBatch(ctx context.Context, opts BatchOptions, build func(ct
if opts.Via != "" {
ctx = WithVia(ctx, opts.Via)
}
// One lock for the whole batch, so a refused change rolled back to
// its savepoint does not let another redirect writer in (WR-10).
if err := lockRedirectWrites(tx); err != nil {
return err
}
changes, err := build(ctx, tx)
if err != nil {
return err

View File

@@ -165,7 +165,14 @@ func classHas(ranges []rune, c rune) bool {
// matches comes back round or does not end within MaxRedirectHops, a
// chain when it takes more than one hop or when another rule's target
// leads into r. A 410 rule ends every chain and is never a loop.
//
// db must be the writer's transaction: CheckRedirect takes the redirect
// write lock (lockRedirectWrites) before it reads, so two concurrent writes
// cannot each pass the simulation without the other's rule.
func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redirect) (RedirectCheck, error) {
if err := lockRedirectWrites(db.WithContext(ctx)); err != nil {
return RedirectCheck{}, err
}
if r.Status == models.Status410 {
return RedirectCheck{}, nil
}
@@ -176,6 +183,18 @@ func (s *Service) CheckRedirect(ctx context.Context, db *gorm.DB, r models.Redir
return checkRedirectRules(s.SiteURL(), rules, r)
}
// redirectLockKey is the transaction-level advisory lock that serialises
// redirect writes ("g15seord").
const redirectLockKey int64 = 0x6731357365_6f7264
// lockRedirectWrites takes the redirect write lock for the rest of tx (it
// is released on commit or rollback). Every path that simulates a redirect
// write takes it before reading the rules (WR-10): at READ COMMITTED the
// next statement after the wait sees what the other writer committed.
func lockRedirectWrites(tx *gorm.DB) error {
return tx.Exec("SELECT pg_advisory_xact_lock(?)", redirectLockKey).Error
}
// checkRedirectRules is CheckRedirect over the stored rules given, for the
// site URL site.
func checkRedirectRules(site string, rules []models.Redirect, r models.Redirect) (RedirectCheck, error) {