fix #63: require deletion token on DELETE /api/pastes/{id}
- 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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user