coverage~bigbes/sr-ht-ecore3bd158fbcsrf/csrf.go

Coverage
100.0% 30/30 statements
Δ
Blob
172961e
Uncovered nothing — every instrumented line ran
1 // Package csrf is the same-origin guard that stands in front of the forms of
2 // every custom service on a self-hosted SourceHut instance (compare, spec,
3 // dolt, cover, bench, ...), and is the one copy of that rule.
4 //
5 // Before this package each service carried its own: tokens.sr.ht and bench and
6 // cover as a router-wide middleware, dolt as a predicate three handlers
7 // remember to call, spec as a predicate one handler pair calls. The copies had
8 // already drifted — byte-equal versus case-insensitive host comparison, four
9 // different refusal sentences, and, in the two services that check per handler,
10 // a form added later that nobody guards at all. This is a security rule, so the
11 // interesting drift is not the wording: it is that in two of five services the
12 // default for a new POST route is *unprotected*. Hence Require, the middleware,
13 // is the API this package leads with, and SameOrigin exists mainly so a service
14 // mid-migration is not forced to move its whole router in one commit.
15 //
16 // # Why a header check and not a token
17 //
18 // There is no CSRF token on any of these services and nowhere to keep one.
19 // Identity is meta.sr.ht's unified-login cookie, set on the parent domain: no
20 // individual service issues it, and none of them can set its SameSite
21 // attribute. A synchronizer-token scheme would mean every daemon inventing a
22 // session store of its own for the sake of two forms.
23 //
24 // What is left is what the browser itself says about where the request came
25 // from. Origin — and Referer, when Origin is absent — are set by the user agent
26 // and cannot be forged from script across origins, which is exactly the
27 // attacker this defends against: a page on another site submitting a form at us
28 // with the viewer's cookie attached.
29 //
30 // # The rule
31 //
32 // Read Origin; failing that, read Referer; compare scheme and host against the
33 // service's own origin; and refuse a request that carries neither.
34 //
35 // That last clause is the one a reader is tempted to relax, and it is the one
36 // holding the guard up. A request that will not say where it came from cannot be
37 // shown to have come from us. Our own forms are same-origin, every engine has
38 // sent Origin on a form POST for years, and a request arriving with neither
39 // header is a script, a stripped proxy or a hand-rolled client — none of which
40 // is the browser this check exists to protect. Waving it through would reduce
41 // the whole guard to a header an attacker's page simply omits.
42 //
43 // Only scheme and host are compared, and the host comparison includes the port,
44 // because that is what an origin is (RFC 6454 §4): a page served from :8443 is
45 // not this origin whatever its hostname says, and http:// is not https://
46 // whatever the host says. The path a Referer carries is ignored — a Referer is
47 // a whole URL and any page of ours is an acceptable referrer for our own form.
48 // The comparison is case-insensitive in both components, since RFC 3986 §3.1
49 // and §3.2.2 say they are: browsers send them lower-cased, but the service's own
50 // origin comes from a config.ini line a human typed, and an operator who wrote
51 // https://Bench.Example.org would otherwise get a service whose every form
52 // answers 403 with nothing in the logs to explain it.
53 //
54 // Safe methods are exempt for RFC 9110 §9.2.1's reason — they change nothing —
55 // and because every read on these surfaces has to keep working from a bookmark,
56 // a README's <img>, a probe on /healthz and a curl with no headers at all.
57 //
58 // # Usage
59 //
60 // The middleware is the one to reach for, on the whole router:
61 //
62 // r.Use(csrf.Require(selfOrigin, func(w http.ResponseWriter, r *http.Request) {
63 // s.renderError(w, r, http.StatusForbidden, csrf.Message)
64 // }))
65 //
66 // Installed there rather than per handler, the guard covers the routes that are
67 // not written yet as well as the ones that are: a POST added to the table below
68 // is protected by having been registered, which is the only form of "do not
69 // forget" that survives a year. Install it after whatever sets the cache
70 // headers, so the refusal page carries the same private, no-store every other
71 // answer of the surface does. It also runs before routing, so a POST to an
72 // address the surface does not serve is refused rather than 404'd — the right
73 // way round, since an unrouted POST answering differently from a routed one
74 // would be a way to enumerate which of them exist without ever passing the
75 // check.
76 //
77 // A service whose bearer-token API lives in its own mux keeps that exemption by
78 // not mounting this middleware there, which is a fact about where Mount is
79 // called and not a path test this package could get wrong. There is deliberately
80 // no "is this /api?" option here and there must never be one: every escape,
81 // every case fold and every dot segment a client can spell would then be a way
82 // to ask for the exemption.
83 //
84 // selfOrigin is the service's own configured origin, e.g.
85 // config.GetOrigin(conf, "bench.sr.ht", true). It is parsed once, when the
86 // middleware is built. If it is not a URL with a scheme and a host, every
87 // mutating request is refused — the failure is closed, because the alternative
88 // is a guard that compares against nothing and admits everyone. Services
89 // validate their origin at startup, so this is unreachable in practice; it is
90 // written down because it is the arm nobody would notice in production except
91 // as "all forms 403".
92 package csrf
93
94 import (
95 "net/http"
96 "net/url"
97 "strings"
98 )
99
100 // Message is what a refused mutation tells the viewer, shared so the five
101 // services answer the same sentence.
102 //
103 // It is deliberately not the services' ownership or visibility refusal: this
104 // says nothing about whether the viewer may do the thing — they may very well
105 // own the object — only that there is no evidence they asked for it. A human
106 // who meets it has usually reached the form through something that stripped the
107 // header, which is worth saying out loud.
108 const Message = "That request did not come from this site, so it was not carried out."
109
110 // Require builds the middleware that refuses a mutating request which cannot
111 // show it came from selfOrigin.
112 //
113 // deny renders the refusal; it is the service's own error page, so that the
114 // refusal looks like the rest of the surface. It should answer 403 and not 400
115 // — the request is well-formed and the viewer may well be logged in, what is
116 // missing is evidence that they asked for this — and it must not redirect: a
117 // redirect after a POST drops the body and would silently turn a refused
118 // mutation into a page that looks like it worked. A nil deny answers a plain
119 // text 403 carrying Message, which is a usable default rather than an invitation
120 // to skip the middleware.
121 //
122 // The returned value has the shape every net/http middleware chain expects,
123 // including chi's Use.
124 16 func Require(selfOrigin string, deny http.HandlerFunc) func(http.Handler) http.Handler {
125 16 // Parsed once here rather than per request: the origin is process-wide
126 16 // configuration, and a middleware built against a broken one should not pay
127 16 // to rediscover that on every POST. ok == false means every mutating request
128 16 // is refused; see the package comment.
129 16 self, ok := parseSelf(selfOrigin)
130 16 if deny == nil {
131 3 deny = denyPlain
132 3 }
133 16 return func(next http.Handler) http.Handler {
134 17 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135 17 if SafeMethod(r.Method) || (ok && claimMatches(r, self)) {
136 7 next.ServeHTTP(w, r)
137 7 return
138 7 }
139 10 deny(w, r)
140 })
141 }
142 }
143
144 // SameOrigin reports whether r names selfOrigin in its Origin header — or,
145 // failing that, in its Referer.
146 //
147 // This is the predicate behind Require, exported for the handler that cannot
148 // yet sit behind the middleware: a route mounted outside the guarded router, or
149 // a service migrating one handler at a time. Prefer Require. A predicate is
150 // protection somebody has to remember, and the services this package was
151 // extracted from are the proof: the two that check per handler are the two
152 // where a form added later would go out unguarded.
153 //
154 // It does not consult the method — a caller reaching for it has already decided
155 // the request mutates. Pair it with SafeMethod if that is not true.
156 25 func SameOrigin(r *http.Request, selfOrigin string) bool {
157 25 self, ok := parseSelf(selfOrigin)
158 25 if !ok {
159 4 return false
160 4 }
161 21 return claimMatches(r, self)
162 }
163
164 // SafeMethod reports whether a method may not change state and is therefore
165 // exempt (RFC 9110 §9.2.1). Everything else — POST today, a PUT, PATCH or
166 // DELETE tomorrow — is checked, which is the direction this list has to fail
167 // in: an unknown method is guarded, not waved through.
168 //
169 // TRACE is on the list because RFC 9110 defines it as safe and because net/http
170 // neither routes nor echoes it by default; leaving it off would have made this
171 // list disagree with the four donors for no gain.
172 29 func SafeMethod(method string) bool {
173 29 switch method {
174 8 case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
175 8 return true
176 21 default:
177 21 return false
178 }
179 }
180
181 // claimMatches applies the rule to a request whose own origin has already
182 // parsed.
183 //
184 // Origin is consulted first and, when present, alone: a browser that sends both
185 // means them to agree, and treating Referer as a second chance after Origin has
186 // already said "somewhere else" would turn the stronger statement into the
187 // weaker one.
188 33 func claimMatches(r *http.Request, self *url.URL) bool {
189 33 if origin := r.Header.Get("Origin"); origin != "" {
190 21 return originMatches(origin, self)
191 21 }
192 12 if referer := r.Header.Get("Referer"); referer != "" {
193 5 return originMatches(referer, self)
194 5 }
195 // Neither header: refuse rather than assume same-origin.
196 7 return false
197 }
198
199 // originMatches reports whether raw — a whole URL, which is what both headers
200 // carry — has self's scheme and host.
201 //
202 // A value that does not parse matches nothing, and so does one that parses to
203 // no scheme or no host: the "null" that a sandboxed iframe or a form redirected
204 // across origins posts is the everyday example, and it must not be mistaken for
205 // "no header", which is refused anyway.
206 26 func originMatches(raw string, self *url.URL) bool {
207 26 u, err := url.Parse(raw)
208 26 if err != nil {
209 2 return false
210 2 }
211 24 return strings.EqualFold(u.Scheme, self.Scheme) && strings.EqualFold(u.Host, self.Host)
212 }
213
214 // parseSelf parses the service's own origin, reporting false for anything that
215 // could not be an origin. Both components are required: a host with no scheme
216 // would compare equal to a protocol-relative "//host/..." claim, and a value
217 // with no host would compare equal to every claim that also has none.
218 41 func parseSelf(selfOrigin string) (*url.URL, bool) {
219 41 u, err := url.Parse(selfOrigin)
220 41 if err != nil || u.Scheme == "" || u.Host == "" {
221 6 return nil, false
222 6 }
223 35 return u, true
224 }
225
226 // denyPlain is the refusal Require uses when the caller supplies none: the
227 // shared sentence, as text, with the status the services' own error pages use.
228 4 func denyPlain(w http.ResponseWriter, _ *http.Request) {
229 4 http.Error(w, Message, http.StatusForbidden)
230 4 }