6 Commits
Author SHA1 Message Date
poslop 44fe3c5772 Merge pull request 'Security headers middleware: CSP, Referrer-Policy, nosniff (#59)' (#76) from issue-59-security-headers into main
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 14:26:01 +00:00
poslop 556d0fa2e1 Merge pull request 'fix #63: require deletion token on DELETE /api/pastes/{id}' (#79) from issue-63-delete-auth into main
CI / test (push) Successful in 18s
CI / docker (push) Skipped
2026-09-09 14:25:40 +00:00
poslop bdd5235f2e Merge pull request 'fix: atomic burn-after-read claim (#58)' (#77) from issue-58-burn-race into main
CI / test (push) Successful in 18s
CI / docker (push) Skipped
2026-09-09 14:25:06 +00:00
poslop 91568c0598 fix #63: require deletion token on DELETE /api/pastes/{id}
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
- DELETE now demands the create-time deletion token (Authorization
  header: Bearer/Token/bare, or ?token= query param), compared with
  the constant-time store.DeletionTokenEqual. 403 otherwise.
- Creator-browser deletes via the /mine button (matching vwr cookie,
  #37) remain allowed; other browsers and plain API clients get 403.
- Regression tests: no token, wrong token (header+query), correct
  token (header+query), creator-cookie path, token extraction.
- Adapted TestSoftDelete to pass the deletion token.
- docs/API.md delete section updated.
- Based on #58's SoftDelete (bool, error) signature.
2026-09-09 09:21:28 -05:00
agent 78374b2d49 fix: atomic burn-after-read claim (#58)
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
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).
2026-09-09 09:18:42 -05:00
palette-agent e08cafe9c8 security headers middleware: CSP, Referrer-Policy, nosniff on HTML pages (#59)
CI / test (pull_request) Successful in 30s
CI / docker (pull_request) Skipped
- web.SecurityHeaders middleware wired into the chi router
- Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' (page scripts are inline); frame-ancestors 'none'
- Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff
- Applied only to text/html responses; JSON API and /raw pass through unchanged
- Regression test internal/web/securityheaders_test.go
2026-09-09 09:16:42 -05:00
9 changed files with 486 additions and 37 deletions
+4 -2
View File
@@ -54,8 +54,10 @@ Raw reads count against a burn-after-read budget, same as page views.
## Delete ## Delete
```bash ```bash
# soft delete (creator browser only; plain API clients unaffected) # soft delete (requires the deletion token from the create response)
curl -X DELETE http://localhost:8080/api/pastes/{id} curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/{id}
# ...or via query param; the creator browser (viewer cookie) may also delete without a token
curl -X DELETE "http://localhost:8080/api/pastes/{id}?token=TOKEN"
# hard delete immediately (requires the one-time deletion token) # hard delete immediately (requires the one-time deletion token)
curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN" curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN"
+103
View File
@@ -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)
}
}
+172
View File
@@ -0,0 +1,172 @@
package api
// Regression tests for #63: DELETE /api/pastes/{id} must require the
// deletion token (Authorization header or ?token= query param, constant-time
// compare). Without a token, or with a wrong token, the paste must survive
// and the response must be 403.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// createTestPaste creates a paste via the API and returns id + deletion token.
func createTestPaste(t *testing.T, h http.Handler) (string, string) {
t.Helper()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"delete me"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
DeletionToken string `json:"deletion_token"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.ID == "" || created.DeletionToken == "" {
t.Fatalf("create response missing id/deletion_token: %s", rec.Body.String())
}
return created.ID, created.DeletionToken
}
func pasteExists(t *testing.T, h http.Handler, id string) bool {
t.Helper()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 200 {
return true
}
if rec.Code == 404 {
return false
}
t.Fatalf("get after delete: got %d", rec.Code)
return false
}
func TestDeleteWithoutTokenForbidden(t *testing.T) {
s := testServer(t)
h := s.routes()
id, _ := createTestPaste(t, h)
rec := doReq(t, h, "DELETE", "/api/pastes/"+id, "", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("delete without token: got %d want 403", rec.Code)
}
if !pasteExists(t, h, id) {
t.Fatal("paste was deleted without a token")
}
}
func TestDeleteWithWrongTokenForbidden(t *testing.T) {
s := testServer(t)
h := s.routes()
id, _ := createTestPaste(t, h)
// query param
rec := doReq(t, h, "DELETE", "/api/pastes/"+id+"?token=wrong-token", "", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("delete with wrong token (query): got %d want 403", rec.Code)
}
// header
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
req.Header.Set("Authorization", "Bearer wrong-token")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("delete with wrong token (header): got %d want 403", rec.Code)
}
if !pasteExists(t, h, id) {
t.Fatal("paste was deleted with a wrong token")
}
}
func TestDeleteWithCorrectToken(t *testing.T) {
s := testServer(t)
h := s.routes()
id, tok := createTestPaste(t, h)
// via Authorization header
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
req.Header.Set("Authorization", "Bearer "+tok)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("delete with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String())
}
if pasteExists(t, h, id) {
t.Fatal("paste still exists after authorized delete")
}
// via query param
id, tok = createTestPaste(t, h)
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "")
if rec.Code != 200 {
t.Fatalf("delete with correct token (query): got %d want 200", rec.Code)
}
if pasteExists(t, h, id) {
t.Fatal("paste still exists after authorized delete (query)")
}
}
func TestDeleteByCreatorViewerCookieStillAllowed(t *testing.T) {
s := testServer(t)
h := s.routes()
// create from a specific browser
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"mine"}`))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator-abc"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
// creator browser deletes without a token: allowed (#37 /mine delete button)
rec = doReq(t, h, "DELETE", "/api/pastes/"+created.ID, "creator-abc", "")
if rec.Code != 200 {
t.Fatalf("creator delete: got %d want 200", rec.Code)
}
// a different browser is still forbidden
id, _ := createTestPaste(t, h)
rec = doReq(t, h, "DELETE", "/api/pastes/"+id, "someone-else", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("other browser delete: got %d want 403", rec.Code)
}
if !pasteExists(t, h, id) {
t.Fatal("paste deleted by unrelated browser")
}
}
func TestDeletionAuthorizationExtract(t *testing.T) {
mk := func(hdr, q string) *http.Request {
req := httptest.NewRequest("DELETE", "/api/pastes/x"+q, nil)
if hdr != "" {
req.Header.Set("Authorization", hdr)
}
return req
}
cases := []struct {
hdr, q, want string
}{
{"", "", ""},
{"Bearer tok", "", "tok"},
{"bearer tok", "", "tok"},
{"Token tok", "", "tok"},
{"tok", "", "tok"},
{"", "?token=q", "q"},
{"Bearer hdr", "?token=q", "hdr"}, // header wins
}
for _, c := range cases {
if got := deletionAuthorization(mk(c.hdr, c.q)); got != c.want {
t.Errorf("deletionAuthorization(hdr=%q q=%q) = %q want %q", c.hdr, c.q, got, c.want)
}
}
}
+18 -6
View File
@@ -42,7 +42,9 @@ func TestCreateAndGetPaste(t *testing.T) {
if rec.Code != 201 { if rec.Code != 201 {
t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String()) t.Fatalf("create: got %d want 201: %s", rec.Code, rec.Body.String())
} }
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
if len(created.ID) != 6 { if len(created.ID) != 6 {
t.Fatalf("unexpected id: %q", created.ID) t.Fatalf("unexpected id: %q", created.ID)
@@ -73,7 +75,9 @@ func TestPasswordProtection(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
// without password -> 401 // without password -> 401
@@ -133,10 +137,14 @@ func TestSoftDelete(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
DeletionToken string `json:"deletion_token"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil) req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil)
req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
if rec.Code != 200 { if rec.Code != 200 {
@@ -166,7 +174,7 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var resp struct { var resp struct {
Total int `json:"total"` Total int `json:"total"`
Items []map[string]any `json:"items"` Items []map[string]any `json:"items"`
} }
json.Unmarshal(rec.Body.Bytes(), &resp) json.Unmarshal(rec.Body.Bytes(), &resp)
@@ -182,7 +190,9 @@ func TestSweepSoftDeletesAfterGrace(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"gone soon"}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
s.store.SoftDelete(created.ID) s.store.SoftDelete(created.ID)
@@ -218,7 +228,9 @@ func TestRawEndpoint(t *testing.T) {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`)) req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"raw content here"}`))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.ServeHTTP(rec, req) h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created) json.Unmarshal(rec.Body.Bytes(), &created)
req = httptest.NewRequest("GET", "/raw/"+created.ID, nil) req = httptest.NewRequest("GET", "/raw/"+created.ID, nil)
+57 -15
View File
@@ -29,11 +29,11 @@ type Config struct {
} }
type apiServer struct { type apiServer struct {
store *store.Store store *store.Store
cfg Config cfg Config
ui *web.UI ui *web.UI
settings *settingsStore settings *settingsStore
adminKey string adminKey string
} }
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer { func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
@@ -60,6 +60,7 @@ func (a *apiServer) routes() http.Handler {
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second)) r.Use(middleware.Timeout(30 * time.Second))
r.Use(viewerCookieMiddleware) r.Use(viewerCookieMiddleware)
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
// admin (#40): HTML page is open (key entry via form); API is key-guarded // admin (#40): HTML page is open (key entry via form); API is key-guarded
r.Get("/admin", a.ui.Handlers().HandleAdminPage) r.Get("/admin", a.ui.Handlers().HandleAdminPage)
@@ -216,7 +217,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return 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{ writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType, "id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt, "language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
@@ -232,21 +238,52 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "paste not found") writeErr(w, 404, "paste not found")
return return
} }
// viewer-cookie delete enforcement (#37): only the browser that created // #63: deletion requires authorization. Either the deletion token issued
// the paste (matching vwr) may delete it via this endpoint. Requests with // at create time (Authorization header or ?token= query param, matching
// no client-sent vwr cookie (plain API clients) are unaffected. // the create response's "deletion_token" field), or the creator browser
vid := currentViewerID(r) // itself (client-sent vwr cookie matching the paste's viewer, #37).
if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid { if !a.deletionAuthorized(r, row) {
writeErr(w, 403, "not your paste") writeErr(w, 403, "deletion token required")
return return
} }
if err := a.store.SoftDelete(row.ID); err != nil { if _, err := a.store.SoftDelete(row.ID); err != nil {
writeErr(w, 500, "db error") writeErr(w, 500, "db error")
return return
} }
writeJSON(w, 200, map[string]string{"status": "soft-deleted"}) writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
} }
// deletionAuthorization extracts the deletion token from the request: the
// Authorization header ("Bearer <t>", "Token <t>", or a bare token) or the
// token query parameter. Returns "" when absent.
func deletionAuthorization(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
for _, prefix := range []string{"Bearer ", "Token "} {
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
return strings.TrimSpace(h[len(prefix):])
}
}
return strings.TrimSpace(h)
}
return r.URL.Query().Get("token")
}
// deletionAuthorized reports whether the request may soft-delete the paste:
// a valid constant-time-matched deletion token, or the creator browser's
// viewer cookie (#37). Plain API clients with no token get false.
func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) bool {
if tok := deletionAuthorization(r); tok != "" {
return row.DeletionToken.Valid && row.DeletionToken.String != "" &&
store.DeletionTokenEqual(row.DeletionToken.String, tok)
}
// viewer-cookie delete enforcement (#37): only the browser that created
// the paste (matching vwr) may delete it via this endpoint. Requests with
// no client-sent vwr cookie (plain API clients) are unaffected.
vid := currentViewerID(r)
return vid != "" && viewerSentCookie(r) && row.ViewerID.Valid &&
row.ViewerID.String != "" && row.ViewerID.String == vid
}
// handleListMine serves /api/mine: pastes created from this browser (#37). // handleListMine serves /api/mine: pastes created from this browser (#37).
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) { func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
vid := currentViewerID(r) vid := currentViewerID(r)
@@ -319,8 +356,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
return return
} }
// #49 decision: raw reads count against the read budget too, with the // #49 decision: raw reads count against the read budget too, with the
// same per-viewer dedupe window as page views. // same per-viewer dedupe window as page views. #58: a reader that loses
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // 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 // #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 // 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 — // render as active content on this origin when fetched from /raw —
+35 -10
View File
@@ -17,23 +17,34 @@ func genDeletionToken() string {
// TimeNow is overridable in tests to inject the clock. // TimeNow is overridable in tests to inject the clock.
var TimeNow = time.Now var TimeNow = time.Now
// registerRead applies the burn-after-read budget for one view (#49). // RegisterRead applies the burn-after-read budget for one view (#49).
// For pastes with reads_limit set: the viewer's paste_views row is checked; // For pastes with reads_limit set: the viewer's paste_views row is checked;
// a view within the burn viewer window of the viewer's last view is deduped // a view within the burn viewer window of the viewer's last view is deduped
// (count=false). Otherwise reads_used is incremented, and the paste is // (count=false). Otherwise reads_used is incremented, and the paste is
// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without // soft-deleted (burned) once reads_used reaches reads_limit. Viewers without
// a cookie (plain API clients) count as their own viewer id "". // a cookie (plain API clients) count as their own viewer id "".
// For legacy plain burn_after_read pastes (no reads_limit), any read burns. // For legacy plain burn_after_read pastes (no reads_limit), any read burns.
// Returns the number of reads remaining (0 when burned), or nil when no // #58: admission is atomic. Returns (remaining, admitted). admitted is true
// budget is set. view_count is tracked separately and unaffected. // only when this caller may serve the content: for legacy burn pastes the
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, count bool) { // caller wins exactly when its conditional soft delete flipped deleted_at
// (RowsAffected), and for read-budget pastes the caller wins exactly when its
// conditional UPDATE (reads_used < reads_limit) incremented the counter - so
// concurrent readers can never both consume the last read. Losing callers
// must treat the paste as gone. view_count is tracked separately and
// unaffected.
func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, admitted bool) {
if !row.ReadsLimit.Valid { if !row.ReadsLimit.Valid {
if row.BurnAfterRead { if row.BurnAfterRead {
s.SoftDelete(row.ID) // #58: atomic claim - only the caller whose UPDATE actually
// flips deleted_at from NULL may serve the content.
ok, err := s.SoftDelete(row.ID)
r := 0 r := 0
if err != nil || !ok {
return &r, false
}
return &r, true return &r, true
} }
return nil, false return nil, true
} }
now := TimeNow().Unix() now := TimeNow().Unix()
var last sql.NullInt64 var last sql.NullInt64
@@ -44,14 +55,28 @@ func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes i
if r < 0 { if r < 0 {
r = 0 r = 0
} }
return &r, false return &r, true
} }
s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?) s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?)
ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`, ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`,
row.ID, viewerID, now) row.ID, viewerID, now)
used := row.ReadsUsed + 1 // #58: conditional increment - only succeeds while budget remains, so
s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID) // concurrent readers cannot both consume the final read.
if int64(used) >= row.ReadsLimit.Int64 { res, err := s.db.Exec(`UPDATE pastes SET reads_used = reads_used + 1
WHERE id = ? AND deleted_at IS NULL AND reads_used < ?`, row.ID, row.ReadsLimit.Int64)
if err != nil {
r := 0
return &r, false
}
if n, _ := res.RowsAffected(); n == 0 {
// Lost the race: budget exhausted (or paste already burned).
r := 0
return &r, false
}
var used int64
s.db.QueryRow(`SELECT reads_used FROM pastes WHERE id = ?`, row.ID).Scan(&used)
if used >= row.ReadsLimit.Int64 {
// Atomic burn; either way the paste is gone for future readers.
s.SoftDelete(row.ID) s.SoftDelete(row.ID)
} }
r := int(row.ReadsLimit.Int64) - int(used) r := int(row.ReadsLimit.Int64) - int(used)
+16 -3
View File
@@ -81,6 +81,11 @@ func OpenStore(path string) (*Store, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// #58: a single write connection. SQLite allows only one writer at a
// time; with multiple pooled connections concurrent writes surface as
// SQLITE_BUSY errors ("database is locked") instead of serializing, and
// the burn-after-read race tests saw spurious 500s under parallel reads.
db.SetMaxOpenConns(1)
s := &Store{db: db} s := &Store{db: db}
if err := s.migrate(); err != nil { if err := s.migrate(); err != nil {
return nil, err return nil, err
@@ -320,9 +325,17 @@ func (s *Store) MineOwner(id string) (string, error) {
return vid.String, nil return vid.String, nil
} }
func (s *Store) SoftDelete(id string) error { // SoftDelete marks a paste deleted (burned) atomically (#58): the deleted_at
_, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id) // IS NULL guard means only the first caller flips the row. Returns true when
return err // this call performed the delete (RowsAffected > 0), false when the paste was
// already deleted - callers use this to decide read admission atomically.
func (s *Store) SoftDelete(id string) (bool, error) {
res, err := s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), id)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
} }
func (s *Store) IncrementViews(id string) { func (s *Store) IncrementViews(id string) {
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// #59: SecurityHeaders must add the CSP and hardening headers to rendered
// HTML responses only; JSON and /raw responses pass through untouched.
func TestSecurityHeaders(t *testing.T) {
pages := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<html><body>ok</body></html>"))
})
h := SecurityHeaders(pages)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
t.Errorf("CSP = %q, want %q", got, wantCSP)
}
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Errorf("Referrer-Policy = %q, want no-referrer", got)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
// JSON response: no security headers.
jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
rec = httptest.NewRecorder()
jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil))
if got := rec.Header().Get("Content-Security-Policy"); got != "" {
t.Errorf("unexpected CSP %q on JSON response", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "" {
t.Errorf("unexpected Referrer-Policy %q on JSON response", got)
}
// Content type set after the first Write (as the inline can page does) is
// still picked up because headers are inspected post-handler.
lateh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html></html>"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}))
rec = httptest.NewRecorder()
lateh.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") {
t.Errorf("CSP = %q, want frame-ancestors 'none'", got)
}
}
+25 -1
View File
@@ -255,7 +255,11 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
// #49: burn-after-N-reads budget (per-viewer dedupe window). // #49: burn-after-N-reads budget (per-viewer dedupe window).
// Just-created first render does not count as a read for the creator. // Just-created first render does not count as a read for the creator.
if !justCreated { if !justCreated {
rem, _ := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin()) rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
if !admitted { // #58: lost the burn claim; do not render content
http.NotFound(w, r)
return
}
h.renderPaste(w, row, false, "", rem) h.renderPaste(w, row, false, "", rem)
return return
} }
@@ -290,3 +294,23 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
// Handlers builds a web.Handlers bound to this UI. // Handlers builds a web.Handlers bound to this UI.
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} } func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
// #59: security headers for rendered HTML pages. Applied wherever the
// response is text/html (page templates and the inline can page); JSON API
// responses and /raw content pass through untouched. script-src allows
// 'unsafe-inline' because the page templates carry inline scripts; CSP
// default-src 'self' still blocks external content and object/frame embeds,
// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
// pentest. Runs after the handler so the Content-Type is already set.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
h := w.Header()
if strings.HasPrefix(h.Get("Content-Type"), "text/html") {
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
}
})
}