The deletion token was accepted via the ?token= query parameter on both
DELETE /api/pastes/{id} and /redeem, and round-tripped through the paste
URL after creation. URL-carried bearer secrets leak into reverse-proxy
access logs and browser history.
- API: deletion tokens are now accepted only via the Authorization header
(Bearer/Token/bare); query params are ignored on both endpoints
- Web create flow: token moves to the browser via a short-lived tok_<id>
HttpOnly cookie instead of the redirect URL; the paste view reads it
from the cookie, never from ?token=
- Web view: the delete button calls redeem() which takes the token from
sessionStorage and sends it as an Authorization header
- Tests: correct token in query must be rejected (403/400); header path
still deletes/redeems; extraction unit cases updated
Fixes #143
207 lines
6.5 KiB
Go
207 lines
6.5 KiB
Go
package api
|
|
|
|
// Regression tests for #63 and #143: DELETE /api/pastes/{id} must require the
|
|
// deletion token in the Authorization header (constant-time compare). The
|
|
// ?token= query parameter is NOT accepted (#143): URL-carried tokens leak
|
|
// into access logs and browser history. 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: even the CORRECT token must be rejected now (#143)
|
|
id2, tok2 := createTestPaste(t, h)
|
|
rec := doReq(t, h, "DELETE", "/api/pastes/"+id2+"?token="+tok2, "", "")
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("delete with correct token in query: got %d want 403 (#143)", rec.Code)
|
|
}
|
|
if !pasteExists(t, h, id2) {
|
|
t.Fatal("paste was deleted via ?token= query param (#143 regression)")
|
|
}
|
|
// 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")
|
|
}
|
|
|
|
// query param: even with the correct token the delete must fail (#143)
|
|
id, tok = createTestPaste(t, h)
|
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "")
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("delete with correct token (query): got %d want 403 (#143)", rec.Code)
|
|
}
|
|
if !pasteExists(t, h, id) {
|
|
t.Fatal("paste was deleted via ?token= query param (#143 regression)")
|
|
}
|
|
}
|
|
|
|
// #143: the deletion token must be accepted via the Authorization header on
|
|
// the redeem (hard delete) endpoint too.
|
|
func TestRedeemWithCorrectTokenHeader(t *testing.T) {
|
|
s := testServer(t)
|
|
h := s.routes()
|
|
id, tok := createTestPaste(t, h)
|
|
|
|
req := httptest.NewRequest("DELETE", "/api/pastes/"+id+"/redeem", nil)
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("redeem 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 redeem")
|
|
}
|
|
|
|
// query param must NOT work on redeem either
|
|
id, tok = createTestPaste(t, h)
|
|
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"/redeem?token="+tok, "", "")
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("redeem via ?token= query: got %d want 400 (#143)", rec.Code)
|
|
}
|
|
if !pasteExists(t, h, id) {
|
|
t.Fatal("paste was hard-deleted via ?token= query param (#143 regression)")
|
|
}
|
|
}
|
|
|
|
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", ""}, // #143: query tokens are never accepted
|
|
{"Bearer hdr", "?token=q", "hdr"}, // header only
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|