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 <img>, 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)
}