~bigbes/sr-ht-ecore

sr-ht-ecore/csrf/csrf.go -rw-r--r-- 10.8 KiB
b36a9272 — Eugene Blikh beads: ignore the JSONL exports a day 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
// Package csrf is the same-origin guard that stands in front of the forms of
// every custom service on a self-hosted SourceHut instance (compare, spec,
// dolt, cover, bench, ...), and is the one copy of that rule.
//
// Before this package each service carried its own: tokens.sr.ht and bench and
// cover as a router-wide middleware, dolt as a predicate three handlers
// remember to call, spec as a predicate one handler pair calls. The copies had
// already drifted — byte-equal versus case-insensitive host comparison, four
// different refusal sentences, and, in the two services that check per handler,
// a form added later that nobody guards at all. This is a security rule, so the
// interesting drift is not the wording: it is that in two of five services the
// default for a new POST route is *unprotected*. Hence Require, the middleware,
// is the API this package leads with, and SameOrigin exists mainly so a service
// mid-migration is not forced to move its whole router in one commit.
//
// # Why a header check and not a token
//
// There is no CSRF token on any of these services and nowhere to keep one.
// Identity is meta.sr.ht's unified-login cookie, set on the parent domain: no
// individual service issues it, and none of them can set its SameSite
// attribute. A synchronizer-token scheme would mean every daemon inventing a
// session store of its own for the sake of two forms.
//
// What is left is what the browser itself says about where the request came
// from. Origin — and Referer, when Origin is absent — are set by the user agent
// and cannot be forged from script across origins, which is exactly the
// attacker this defends against: a page on another site submitting a form at us
// with the viewer's cookie attached.
//
// # The rule
//
// Read Origin; failing that, read Referer; compare scheme and host against the
// service's own origin; and refuse a request that carries neither.
//
// That last clause is the one a reader is tempted to relax, and it is the one
// holding the guard up. A request that will not say where it came from cannot be
// shown to have come from us. Our own forms are same-origin, every engine has
// sent Origin on a form POST for years, and a request arriving with neither
// header is a script, a stripped proxy or a hand-rolled client — none of which
// is the browser this check exists to protect. Waving it through would reduce
// the whole guard to a header an attacker's page simply omits.
//
// Only scheme and host are compared, and the host comparison includes the port,
// because that is what an origin is (RFC 6454 §4): a page served from :8443 is
// not this origin whatever its hostname says, and http:// is not https://
// whatever the host says. The path a Referer carries is ignored — a Referer is
// a whole URL and any page of ours is an acceptable referrer for our own form.
// The comparison is case-insensitive in both components, since RFC 3986 §3.1
// and §3.2.2 say they are: browsers send them lower-cased, but the service's own
// origin comes from a config.ini line a human typed, and an operator who wrote
// https://Bench.Example.org would otherwise get a service whose every form
// answers 403 with nothing in the logs to explain it.
//
// Safe methods are exempt for RFC 9110 §9.2.1's reason — they change nothing —
// and because every read on these surfaces has to keep working from a bookmark,
// a README's <img>, a probe on /healthz and a curl with no headers at all.
//
// # Usage
//
// The middleware is the one to reach for, on the whole router:
//
//	r.Use(csrf.Require(selfOrigin, func(w http.ResponseWriter, r *http.Request) {
//	    s.renderError(w, r, http.StatusForbidden, csrf.Message)
//	}))
//
// Installed there rather than per handler, the guard covers the routes that are
// not written yet as well as the ones that are: a POST added to the table below
// is protected by having been registered, which is the only form of "do not
// forget" that survives a year. Install it after whatever sets the cache
// headers, so the refusal page carries the same private, no-store every other
// answer of the surface does. It also runs before routing, so a POST to an
// address the surface does not serve is refused rather than 404'd — the right
// way round, since an unrouted POST answering differently from a routed one
// would be a way to enumerate which of them exist without ever passing the
// check.
//
// A service whose bearer-token API lives in its own mux keeps that exemption by
// not mounting this middleware there, which is a fact about where Mount is
// called and not a path test this package could get wrong. There is deliberately
// no "is this /api?" option here and there must never be one: every escape,
// every case fold and every dot segment a client can spell would then be a way
// to ask for the exemption.
//
// selfOrigin is the service's own configured origin, e.g.
// config.GetOrigin(conf, "bench.sr.ht", true). It is parsed once, when the
// middleware is built. If it is not a URL with a scheme and a host, every
// mutating request is refused — the failure is closed, because the alternative
// is a guard that compares against nothing and admits everyone. Services
// validate their origin at startup, so this is unreachable in practice; it is
// written down because it is the arm nobody would notice in production except
// as "all forms 403".
package csrf

import (
	"net/http"
	"net/url"
	"strings"
)

// Message is what a refused mutation tells the viewer, shared so the five
// services answer the same sentence.
//
// It is deliberately not the services' ownership or visibility refusal: this
// says nothing about whether the viewer may do the thing — they may very well
// own the object — only that there is no evidence they asked for it. A human
// who meets it has usually reached the form through something that stripped the
// header, which is worth saying out loud.
const Message = "That request did not come from this site, so it was not carried out."

// Require builds the middleware that refuses a mutating request which cannot
// show it came from selfOrigin.
//
// deny renders the refusal; it is the service's own error page, so that the
// refusal looks like the rest of the surface. It should answer 403 and not 400
// — the request is well-formed and the viewer may well be logged in, what is
// missing is evidence that they asked for this — and it must not redirect: a
// redirect after a POST drops the body and would silently turn a refused
// mutation into a page that looks like it worked. A nil deny answers a plain
// text 403 carrying Message, which is a usable default rather than an invitation
// to skip the middleware.
//
// The returned value has the shape every net/http middleware chain expects,
// including chi's Use.
func Require(selfOrigin string, deny http.HandlerFunc) func(http.Handler) http.Handler {
	// Parsed once here rather than per request: the origin is process-wide
	// configuration, and a middleware built against a broken one should not pay
	// to rediscover that on every POST. ok == false means every mutating request
	// is refused; see the package comment.
	self, ok := parseSelf(selfOrigin)
	if deny == nil {
		deny = denyPlain
	}
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if SafeMethod(r.Method) || (ok && claimMatches(r, self)) {
				next.ServeHTTP(w, r)
				return
			}
			deny(w, r)
		})
	}
}

// SameOrigin reports whether r names selfOrigin in its Origin header — or,
// failing that, in its Referer.
//
// This is the predicate behind Require, exported for the handler that cannot
// yet sit behind the middleware: a route mounted outside the guarded router, or
// a service migrating one handler at a time. Prefer Require. A predicate is
// protection somebody has to remember, and the services this package was
// extracted from are the proof: the two that check per handler are the two
// where a form added later would go out unguarded.
//
// It does not consult the method — a caller reaching for it has already decided
// the request mutates. Pair it with SafeMethod if that is not true.
func SameOrigin(r *http.Request, selfOrigin string) bool {
	self, ok := parseSelf(selfOrigin)
	if !ok {
		return false
	}
	return claimMatches(r, self)
}

// SafeMethod reports whether a method may not change state and is therefore
// exempt (RFC 9110 §9.2.1). Everything else — POST today, a PUT, PATCH or
// DELETE tomorrow — is checked, which is the direction this list has to fail
// in: an unknown method is guarded, not waved through.
//
// TRACE is on the list because RFC 9110 defines it as safe and because net/http
// neither routes nor echoes it by default; leaving it off would have made this
// list disagree with the four donors for no gain.
func SafeMethod(method string) bool {
	switch method {
	case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
		return true
	default:
		return false
	}
}

// claimMatches applies the rule to a request whose own origin has already
// parsed.
//
// Origin is consulted first and, when present, alone: a browser that sends both
// means them to agree, and treating Referer as a second chance after Origin has
// already said "somewhere else" would turn the stronger statement into the
// weaker one.
func claimMatches(r *http.Request, self *url.URL) bool {
	if origin := r.Header.Get("Origin"); origin != "" {
		return originMatches(origin, self)
	}
	if referer := r.Header.Get("Referer"); referer != "" {
		return originMatches(referer, self)
	}
	// Neither header: refuse rather than assume same-origin.
	return false
}

// originMatches reports whether raw — a whole URL, which is what both headers
// carry — has self's scheme and host.
//
// A value that does not parse matches nothing, and so does one that parses to
// no scheme or no host: the "null" that a sandboxed iframe or a form redirected
// across origins posts is the everyday example, and it must not be mistaken for
// "no header", which is refused anyway.
func originMatches(raw string, self *url.URL) bool {
	u, err := url.Parse(raw)
	if err != nil {
		return false
	}
	return strings.EqualFold(u.Scheme, self.Scheme) && strings.EqualFold(u.Host, self.Host)
}

// parseSelf parses the service's own origin, reporting false for anything that
// could not be an origin. Both components are required: a host with no scheme
// would compare equal to a protocol-relative "//host/..." claim, and a value
// with no host would compare equal to every claim that also has none.
func parseSelf(selfOrigin string) (*url.URL, bool) {
	u, err := url.Parse(selfOrigin)
	if err != nil || u.Scheme == "" || u.Host == "" {
		return nil, false
	}
	return u, true
}

// denyPlain is the refusal Require uses when the caller supplies none: the
// shared sentence, as text, with the status the services' own error pages use.
func denyPlain(w http.ResponseWriter, _ *http.Request) {
	http.Error(w, Message, http.StatusForbidden)
}