~bigbes/sr-ht-ecore

ref: 12ae8522b3f9eb04a733f20010ec5a7ca099cec1 sr-ht-ecore/csrf/csrf_test.go -rw-r--r-- 11.7 KiB
12ae8522 — Eugene Blikh ecoretest: the shared instance config and crypto bootstrap for tests 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
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)
}