fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report)
This commit is contained in:
1 parent
e2812ee0b9
commit
ea98c5ec05
118 files changed
+3828
-130
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/middleware"
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newCrossOriginProtectedHandler(publicURL string) (http.Handler, *int) {
|
||||
reached := 0
|
||||
handler := middleware.CrossOriginProtection(publicURL)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
reached++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
return handler, &reached
|
||||
}
|
||||
|
||||
func sendCrossOriginRequest(handler http.Handler, method string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, "https://cms.example.com/v1/user/invite", strings.NewReader(`{"email":"attacker@evil.example"}`))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: "session"})
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestCrossOriginProtection(t *testing.T) {
|
||||
refused := []struct {
|
||||
name string
|
||||
method string
|
||||
headers map[string]string
|
||||
}{
|
||||
{"a cross-site form POST", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
|
||||
{"a same-site but cross-origin POST from a sibling subdomain", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://blog.example.com"}},
|
||||
{"a cross-site DELETE", http.MethodDelete, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
|
||||
{"an old browser's cross-origin POST without Sec-Fetch-Site", http.MethodPost, map[string]string{"Origin": "https://evil.example"}},
|
||||
}
|
||||
for _, tc := range refused {
|
||||
t.Run("Refuses "+tc.name, func(t *testing.T) {
|
||||
handler, reached := newCrossOriginProtectedHandler("https://cms.example.com")
|
||||
|
||||
rec := sendCrossOriginRequest(handler, tc.method, tc.headers)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), apperr.CrossOriginErrorMessage) {
|
||||
t.Errorf("expected the cross-origin message, got %q", rec.Body.String())
|
||||
}
|
||||
if *reached != 0 {
|
||||
t.Error("the handler must not run")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
allowed := []struct {
|
||||
name string
|
||||
method string
|
||||
headers map[string]string
|
||||
}{
|
||||
{"a same-origin POST from the SPA", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}},
|
||||
{"a request typed in the address bar", http.MethodPost, map[string]string{"Sec-Fetch-Site": "none"}},
|
||||
{"a non-browser client with neither header", http.MethodPost, nil},
|
||||
{"a cross-site GET", http.MethodGet, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
|
||||
{"a cross-site preflight", http.MethodOptions, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
|
||||
{"a cross-origin POST carrying an explicit Authorization header", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://shop.example", "Authorization": "Bearer rcms_key"}},
|
||||
{"an old browser's POST from the public URL behind a proxy that rewrites Host", http.MethodPost, map[string]string{"Origin": "https://cms.example.com", "Host": "127.0.0.1:8080"}},
|
||||
}
|
||||
for _, tc := range allowed {
|
||||
t.Run("Allows "+tc.name, func(t *testing.T) {
|
||||
handler, reached := newCrossOriginProtectedHandler("https://cms.example.com")
|
||||
req := httptest.NewRequest(tc.method, "https://cms.example.com/v1/user/invite", nil)
|
||||
for name, value := range tc.headers {
|
||||
if name == "Host" {
|
||||
req.Host = value
|
||||
continue
|
||||
}
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || *reached != 1 {
|
||||
t.Errorf("expected the handler to run, got %d (reached %d)", rec.Code, *reached)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("An unparsable public URL still protects against cross-site requests", func(t *testing.T) {
|
||||
handler, reached := newCrossOriginProtectedHandler("::not a url")
|
||||
|
||||
rec := sendCrossOriginRequest(handler, http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"})
|
||||
|
||||
if rec.Code != http.StatusForbidden || *reached != 0 {
|
||||
t.Errorf("expected 403 without reaching the handler, got %d (reached %d)", rec.Code, *reached)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user