fix: atomic burn-after-read claim (#58)
SoftDelete now reports whether it performed the delete (conditional UPDATE ... WHERE deleted_at IS NULL checked via RowsAffected). RegisterRead returns an admitted flag: legacy burn pastes admit exactly one reader (the atomic soft-delete winner), and burn-after-N pastes increment reads_used via a conditional UPDATE guarded on reads_used < reads_limit, so concurrent readers cannot both consume the final read. API, HTML, and raw read paths return 404 when the reader loses the burn claim; content is never served twice. OpenStore pins the SQLite pool to one connection: concurrent writes on separate pooled connections surfaced SQLITE_BUSY as spurious 500s instead of serializing. Adds concurrency regression tests: 24 parallel readers of a burn paste (exactly one receives content, none of the others leak it) and 30 parallel readers vs a 3-read budget (exactly 3 admitted, then 404).
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
|
||||
// readers of a burn-after-read paste must receive exactly one success with
|
||||
// content; every other reader must get 404 and never any content.
|
||||
func TestBurnAfterReadConcurrentRace(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
id := createBurnReads(t, h, 1)
|
||||
|
||||
const readers = 24
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wins, losses := 0, 0
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if rec.Code == 200 {
|
||||
wins++
|
||||
var got struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
|
||||
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
|
||||
}
|
||||
} else if rec.Code == 404 {
|
||||
losses++
|
||||
if strings.Contains(rec.Body.String(), "limited") {
|
||||
t.Errorf("losing read leaked content: %s", rec.Body.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins != 1 {
|
||||
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
|
||||
}
|
||||
if losses != readers-1 {
|
||||
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
|
||||
// more concurrent distinct readers than the budget: total admissions must
|
||||
// equal exactly N, and no losing read may see content.
|
||||
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
const budget = 3
|
||||
id := createBurnReads(t, h, budget)
|
||||
|
||||
const readers = 30
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wins := 0
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if rec.Code == 200 {
|
||||
wins++
|
||||
} else if rec.Code != 404 {
|
||||
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if wins != budget {
|
||||
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
|
||||
}
|
||||
|
||||
// After the race, the paste is burned for everyone.
|
||||
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
|
||||
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -29,11 +29,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
store *store.Store
|
||||
cfg Config
|
||||
ui *web.UI
|
||||
settings *settingsStore
|
||||
adminKey string
|
||||
store *store.Store
|
||||
cfg Config
|
||||
ui *web.UI
|
||||
settings *settingsStore
|
||||
adminKey string
|
||||
}
|
||||
|
||||
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
|
||||
@@ -216,7 +216,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
rem, _ := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // #49 (also covers legacy burn)
|
||||
// #58: only the reader that wins the atomic burn claim may see content.
|
||||
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
if !admitted {
|
||||
writeErr(w, 404, "paste not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
||||
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
|
||||
@@ -240,7 +245,7 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 403, "not your paste")
|
||||
return
|
||||
}
|
||||
if err := a.store.SoftDelete(row.ID); err != nil {
|
||||
if _, err := a.store.SoftDelete(row.ID); err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
@@ -319,8 +324,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// #49 decision: raw reads count against the read budget too, with the
|
||||
// same per-viewer dedupe window as page views.
|
||||
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
// same per-viewer dedupe window as page views. #58: a reader that loses
|
||||
// the burn claim must not receive the content.
|
||||
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
if !admitted {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
// #34: content_type is attacker-controlled via the create API. Serving it
|
||||
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
||||
// render as active content on this origin when fetched from /raw —
|
||||
|
||||
Reference in New Issue
Block a user