package csrf import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // selfOrigin is the origin under test throughout: an https service on the // default port, spelled the way a config.ini line would be. const selfOrigin = "https://bench.example.org" // request builds a POST carrying the given headers; a header with an empty // value is not set at all, which is how a request that omits it looks to // Header.Get. func request(method string, headers map[string]string) *http.Request { r := httptest.NewRequest(method, "/settings", nil) for k, v := range headers { if v != "" { r.Header.Set(k, v) } } return r } func TestSameOrigin(t *testing.T) { tests := []struct { name string self string origin string referer string want bool }{ { name: "matching Origin", self: selfOrigin, origin: "https://bench.example.org", want: true, }, { name: "matching Origin differing only in case", self: "https://Bench.Example.org", origin: "https://bench.example.org", want: true, // The operator types the config line; the browser sends the // lower-cased form. Byte equality would 403 every form on this // instance with nothing in the logs to explain it. }, { name: "mismatching Origin", self: selfOrigin, origin: "https://evil.example.com", want: false, }, { name: "Origin whose host is a suffix of ours", self: selfOrigin, origin: "https://notbench.example.org", want: false, }, { name: "Origin that only prefixes ours", self: selfOrigin, origin: "https://bench.example.org.evil.com", want: false, }, { name: "scheme mismatch", self: selfOrigin, origin: "http://bench.example.org", want: false, }, { name: "port mismatch", self: selfOrigin, origin: "https://bench.example.org:8443", want: false, // The port is part of the origin (RFC 6454 §4): a page served from // :8443 is not this origin whatever its hostname says. }, { name: "our port spelled out where ours has none", self: selfOrigin, origin: "https://bench.example.org:443", want: false, // Refused deliberately: this compares hosts, not effective ports. // Browsers elide the default port, so a real request never looks // like this, and normalising it would mean teaching this package // every scheme's default. }, { name: "matching Referer with no Origin", self: selfOrigin, referer: "https://bench.example.org/~bigbes/foo", want: true, // The path is ignored: any page of ours may refer to our own form. }, { name: "matching Referer, bare origin with no path", self: selfOrigin, referer: "https://bench.example.org", want: true, }, { name: "mismatching Referer", self: selfOrigin, referer: "https://evil.example.com/attack.html", want: false, }, { name: "neither header", self: selfOrigin, want: false, // The clause the whole guard rests on. A request that will not say // where it came from cannot be shown to have come from us, and // admitting it would reduce the check to a header an attacker's // page simply omits. }, { name: "mismatching Origin beats a matching Referer", self: selfOrigin, origin: "https://evil.example.com", referer: "https://bench.example.org/~bigbes/foo", want: false, // Referer is the fallback for an absent Origin, not a second chance // after Origin has already said "somewhere else". }, { name: "matching Origin outvotes a foreign Referer", self: selfOrigin, origin: "https://bench.example.org", referer: "https://evil.example.com/attack.html", want: true, }, { name: "the null origin of a sandboxed iframe", self: selfOrigin, origin: "null", want: false, }, { name: "malformed Origin", self: selfOrigin, origin: "http://[::1", want: false, }, { name: "Origin that is not a URL at all", self: selfOrigin, origin: "not a url", want: false, }, { name: "protocol-relative Origin naming our host", self: selfOrigin, origin: "//bench.example.org", want: false, // Parses to our host with no scheme. Refused, because the guard // compares schemes too and the empty one is not https. }, { name: "malformed own origin", self: "http://[::1", origin: "http://[::1", want: false, // Fails closed: a broken config line refuses every mutation rather // than making every claim match a nil comparison. }, { name: "own origin with no scheme", self: "bench.example.org", origin: "https://bench.example.org", want: false, }, { name: "empty own origin", self: "", origin: "https://bench.example.org", want: false, }, { name: "empty own origin and no headers", self: "", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := request(http.MethodPost, map[string]string{ "Origin": tt.origin, "Referer": tt.referer, }) assert.Equal(t, tt.want, SameOrigin(r, tt.self)) }) } } // SameOrigin says nothing about the method — a caller reaching for the // predicate has already decided the request mutates. func TestSameOriginIgnoresTheMethod(t *testing.T) { for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { r := request(method, nil) assert.False(t, SameOrigin(r, selfOrigin), "%s with no headers", method) } } func TestSafeMethod(t *testing.T) { tests := []struct { method string want bool }{ {http.MethodGet, true}, {http.MethodHead, true}, {http.MethodOptions, true}, {http.MethodTrace, true}, {http.MethodPost, false}, {http.MethodPut, false}, {http.MethodPatch, false}, {http.MethodDelete, false}, {http.MethodConnect, false}, {"PROPFIND", false}, {"", false}, {"get", false}, // methods are case-sensitive; an unknown one is guarded } for _, tt := range tests { t.Run(tt.method, func(t *testing.T) { assert.Equal(t, tt.want, SafeMethod(tt.method)) }) } } func TestRequire(t *testing.T) { tests := []struct { name string self string method string origin string referer string wantServed bool }{ { name: "matching Origin on a POST", self: selfOrigin, method: http.MethodPost, origin: "https://bench.example.org", wantServed: true, }, { name: "mismatching Origin on a POST", self: selfOrigin, method: http.MethodPost, origin: "https://evil.example.com", wantServed: false, }, { name: "matching Referer with no Origin", self: selfOrigin, method: http.MethodPost, referer: "https://bench.example.org/settings", wantServed: true, }, { name: "mismatching Referer", self: selfOrigin, method: http.MethodPost, referer: "https://evil.example.com/attack.html", wantServed: false, }, { name: "POST with neither header", self: selfOrigin, method: http.MethodPost, wantServed: false, }, { name: "DELETE with neither header", self: selfOrigin, method: http.MethodDelete, wantServed: false, // The methods nobody has written a route for yet are guarded by // default; that is the whole reason this is a middleware. }, { name: "PUT with a matching Origin", self: selfOrigin, method: http.MethodPut, origin: "https://bench.example.org", wantServed: true, }, { name: "GET with no headers at all", self: selfOrigin, method: http.MethodGet, wantServed: true, // A bookmark, a README's , a probe on /healthz. }, { name: "HEAD from another site", self: selfOrigin, method: http.MethodHead, origin: "https://evil.example.com", wantServed: true, }, { name: "OPTIONS preflight from another site", self: selfOrigin, method: http.MethodOptions, origin: "https://evil.example.com", wantServed: true, }, { name: "malformed Origin on a POST", self: selfOrigin, method: http.MethodPost, origin: "http://[::1", wantServed: false, }, { name: "broken own origin refuses a matching POST", self: "not-an-origin", method: http.MethodPost, origin: "https://bench.example.org", wantServed: false, }, { name: "broken own origin still serves a GET", self: "not-an-origin", method: http.MethodGet, wantServed: true, // Fail-closed applies to mutations. A misconfigured origin must not // take the whole read surface down with it. }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var served, denied bool next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { served = true w.WriteHeader(http.StatusNoContent) }) deny := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { denied = true http.Error(w, Message, http.StatusForbidden) }) w := httptest.NewRecorder() Require(tt.self, deny)(next).ServeHTTP(w, request(tt.method, map[string]string{ "Origin": tt.origin, "Referer": tt.referer, })) assert.Equal(t, tt.wantServed, served, "handler reached") assert.Equal(t, !tt.wantServed, denied, "refusal rendered") if tt.wantServed { assert.Equal(t, http.StatusNoContent, w.Code) } else { assert.Equal(t, http.StatusForbidden, w.Code) } }) } } // A nil deny is a usable default rather than a reason to skip the middleware. func TestRequireDefaultRefusal(t *testing.T) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("handler must not be reached") }) w := httptest.NewRecorder() Require(selfOrigin, nil)(next).ServeHTTP(w, request(http.MethodPost, nil)) require.Equal(t, http.StatusForbidden, w.Code) assert.Equal(t, Message, strings.TrimSpace(w.Body.String())) } // The refused request's body must not reach the handler, and the refusal must // not be a redirect: a redirect after a POST drops the body and turns a refused // mutation into a page that looks like it worked. func TestRequireRefusalIsNotARedirect(t *testing.T) { w := httptest.NewRecorder() next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("handler must not be reached") }) r := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader("name=x")) r.Header.Set("Origin", "https://evil.example.com") Require(selfOrigin, nil)(next).ServeHTTP(w, r) require.Equal(t, http.StatusForbidden, w.Code) assert.Empty(t, w.Header().Get("Location")) } // The guard runs before routing, so a mutating request to an address the // service does not serve is refused rather than answered 404 — an unrouted POST // that answered differently would enumerate which routes exist without ever // passing the check. func TestRequireRunsBeforeRouting(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) guarded := Require(selfOrigin, nil)(mux) for _, target := range []string{"/settings", "/no/such/route"} { w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPost, target, nil) r.Header.Set("Origin", "https://evil.example.com") guarded.ServeHTTP(w, r) assert.Equal(t, http.StatusForbidden, w.Code, "POST %s", target) } } // Message is part of the package's contract: the five services answer the same // sentence, and a service's own error page renders it verbatim. func TestMessageIsShared(t *testing.T) { assert.Equal(t, "That request did not come from this site, so it was not carried out.", Message) }