diff --git a/docs/API.md b/docs/API.md index cd9562e..5dfb1c4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -54,8 +54,10 @@ Raw reads count against a burn-after-read budget, same as page views. ## Delete ```bash -# soft delete (creator browser only; plain API clients unaffected) -curl -X DELETE http://localhost:8080/api/pastes/{id} +# soft delete (requires the deletion token from the create response) +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) curl -X DELETE "http://localhost:8080/api/pastes/{id}/redeem?token=TOKEN" diff --git a/internal/api/delete_auth_test.go b/internal/api/delete_auth_test.go new file mode 100644 index 0000000..9581b1d --- /dev/null +++ b/internal/api/delete_auth_test.go @@ -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) + } + } +} diff --git a/internal/api/main_test.go b/internal/api/main_test.go index 1ece18c..8220d29 100644 --- a/internal/api/main_test.go +++ b/internal/api/main_test.go @@ -42,7 +42,9 @@ func TestCreateAndGetPaste(t *testing.T) { if rec.Code != 201 { 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) if len(created.ID) != 6 { 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)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) - var created struct{ ID string `json:"id"` } + var created struct { + ID string `json:"id"` + } json.Unmarshal(rec.Body.Bytes(), &created) // without password -> 401 @@ -133,10 +137,14 @@ func TestSoftDelete(t *testing.T) { req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"bye"}`)) rec := httptest.NewRecorder() 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) req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID, nil) + req.Header.Set("Authorization", "Bearer "+created.DeletionToken) rec = httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { @@ -166,7 +174,7 @@ func TestListPublicExcludesUnlisted(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var resp struct { - Total int `json:"total"` + Total int `json:"total"` Items []map[string]any `json:"items"` } 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"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) - var created struct{ ID string `json:"id"` } + var created struct { + ID string `json:"id"` + } json.Unmarshal(rec.Body.Bytes(), &created) 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"}`)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) - var created struct{ ID string `json:"id"` } + var created struct { + ID string `json:"id"` + } json.Unmarshal(rec.Body.Bytes(), &created) req = httptest.NewRequest("GET", "/raw/"+created.ID, nil) diff --git a/internal/api/server.go b/internal/api/server.go index 57f9037..3556706 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -237,12 +237,12 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "paste not found") return } - // 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) - if vid != "" && viewerSentCookie(r) && row.ViewerID.Valid && row.ViewerID.String != "" && row.ViewerID.String != vid { - writeErr(w, 403, "not your paste") + // #63: deletion requires authorization. Either the deletion token issued + // at create time (Authorization header or ?token= query param, matching + // the create response's "deletion_token" field), or the creator browser + // itself (client-sent vwr cookie matching the paste's viewer, #37). + if !a.deletionAuthorized(r, row) { + writeErr(w, 403, "deletion token required") return } 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"}) } +// deletionAuthorization extracts the deletion token from the request: the +// Authorization header ("Bearer ", "Token ", 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). func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) { vid := currentViewerID(r)