| 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 |
} |