fix #63: require deletion token on DELETE /api/pastes/{id} #79
+4
-2
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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)
|
||||||
|
|||||||
+37
-6
@@ -237,12 +237,12 @@ 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 {
|
||||||
@@ -252,6 +252,37 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
|||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user