CSRF: Attack Vectors and Defense Mechanisms
Cross-Site Request Forgery (CSRF) is a type of attack where an attacker tricks an authenticated user's browser into sending an unwanted HTTP request to a web application. Unlike XSS, the goal of CSRF is not data theft, but rather performing actions on behalf of the user.
The fundamental cause of this vulnerability lies in the fact that browsers automatically attach authentication data (primarily Cookies) to requests made to corresponding domains, regardless of which resource initiated the request.
Attack Vectors #
To exploit this vulnerability, an attacker creates a page that inconspicuously sends a request to the vulnerable server. This page doesn't need to be a fake version of the vulnerable site—it could be an article, a news post, or anything else. The key is to trick the user into visiting it; the attack will be carried out silently and may not raise any suspicion.
Let's examine the ways this attack can be implemented.
Attack via HTML Form #
The simplest and most common variant is a standard HTML form.
For example, on our Mission Control Center website, there is a rocket launch form available only to the Flight Director:
1<!-- Form on mission-control.internal -->
2<form
3 action="/launch"
4 method="POST"
5>
6 <select name="launchpad">
7 <option value="lc-39a">LC-39A</option>
8 <option value="lc-39b">LC-39B</option>
9 <option value="slc-40">SLC-40</option>
10 <option value="slc-41">SLC-41</option>
11 </select>
12 <button type="submit" class="big-red-button">
13 LAUNCH
14 </button>
15</form>
The form is processed by the backend and initiates a rocket launch at the specified pad:
1func launchHandler(w http.ResponseWriter, r *http.Request) {
2 // Session check
3 sessionCookie, _ := r.Cookie("session_id")
4 session := getSession(sessionCookie)
5 if session == nil || session.Role != "FlightDirector" {
6 http.Error(w, "Access only for Flight Director", http.StatusUnauthorized)
7 return
8 }
9 // Reading form data
10 launchpad := r.FormValue("launchpad")
11 // Executing a critical action
12 spaceAPI.InitiateLaunch(launchpad)
13 fmt.Fprintf(w, "Rocket on pad %s launched!", launchpad)
14}
At first glance, the method might seem secure—it includes session and role checks.
However, it contains no verification of where the request originated. An attacker can create a page with interesting information and send a link to the unsuspecting Flight Director:
1<html>
2 <head>
3 <title>Photos from the Annual Reptilian Meeting</title>
4 </head>
5 <body>
6 <img src="/elon.jpg" />
7 <img src="/mark.jpg" />
8
9 <!-- hidden form on onlyreptiloids.tld -->
10 <form
11 action="https://mission-control.internal/launch"
12 method="POST"
13 id="attack-form"
14 >
15 <input type="hidden" name="launchpad" value="slc-40" />
16 </form>
17 <script>
18 // Automatic form submission
19 document.getElementById("attack-form").submit();
20 </script>
21 </body>
22</html>
The victim only needs to visit this page for the form to be automatically submitted to the target site.
Since the Content-Type of this form is application/x-www-form-urlencoded, the browser will send the request along with the session cookie without any preliminary checks (CORS preflight).
Attack via GET Request #
It is worth remembering that GET requests should, by definition, be idempotent—they should not lead to changes in the system state. In the context of CSRF, such requests are considered safe by default, and protective mechanisms for them are not mandatory. However, in practice, developers often neglect this rule or allow the use of the GET method instead of POST by mistake.
We made such an error in the example above with the implementation of launchHandler—we didn't check the HTTP method and used r.FormValue, which extracts data not only from the form but also from query parameters.
As a result, an attacker doesn't even need to submit a form—it is enough to construct a link with these parameters and, for example, specify it as the src for an image:
1<img src="/elon.jpg" />
2<img src="/mark.jpg" />
3<!-- Dangerous image: -->
4<img src="https://mission-control.internal/launch?launchpad=slc-40" />
For the browser, there is no difference between requesting an image and calling an API method. As soon as the Flight Director opens this page, the browser will attempt to load the "image" along with the session cookies, and the insecure backend method, not distinguishing GET from POST, will execute the action.
Attack via XHR (XMLHttpRequest and fetch) #
This method can be applied when it is necessary to perform a complex request (for example, to a JSON-RPC API) that cannot be implemented via a standard form.
1// script on the reptilian site
2fetch("https://mission-control.internal/api/adjust-orbit", {
3 method: "POST",
4 headers: {
5 "Content-Type": "text/plain" // Bypassing browser preflight check
6 },
7 body: JSON.stringify({
8 "thruster_id": "main-engine",
9 "action": "burn",
10 "duration_sec": 600
11 }),
12 credentials: "include" // Forcing cookies to be sent with the request
13});
In modern browsers, such attacks are almost entirely blocked by the CORS mechanism and Same-Origin Policy. Therefore, for this to work, specific configuration errors must be present on the server:
- Insecure CORS configuration: requests are allowed from arbitrary domains and credential transfer is enabled (
Access-Control-Allow-Credentials: true). - Weakened Cookie protection: session cookies have the
SameSite=Noneattribute, or the user is using an outdated browser.
Defense Methods #
To protect against CSRF attacks, we must ensure that the request originated from a trusted interface.
Attention!
Code examples are provided for demonstration purposes only. For readability, they may lack validation, error handling, and other simplifications. Please do not use them "as is" in your projects. More detailed and accurate examples can be found in related articles dedicated to these topics.
Cookie Parameter Configuration (SameSite) #
Modern browsers support the SameSite attribute for the Set-Cookie header, which restricts cookie transmission during cross-domain requests:
- Strict: Cookies are sent only within the same site. This is effective for preventing most attacks, but it can be inconvenient for the user—when navigating to the site via an external link (for example, from your own dashboard on a different domain), the user will find themselves logged out.
- Lax: Cookies are not sent with cross-domain POST requests but are allowed during standard link navigations (GET). This value is used by default in Go in most modern frameworks and libraries.
1// Setting a cookie with correct attributes
2http.SetCookie(w, &http.Cookie{
3 Name: "session_id",
4 Value: sessionId,
5 Path: "/",
6 HttpOnly: true, // Protection against XSS
7 Secure: true, // Only via HTTPS
8 // CSRF protection at the browser level:
9 SameSite: http.SameSiteLaxMode,
10})
It is worth remembering that these are relatively new attributes; old browsers may ignore them, so other defense methods should definitely be applied as well.
Origin and Referer Header Verification #
The server can check the Origin or Referer headers to ensure that the request came from a trusted domain.
1// Example middleware for Origin checking
2func OriginCheckMiddleware(next http.Handler) http.Handler {
3 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
4 // Check only mutating methods
5 if r.Method == http.MethodPost || r.Method == http.MethodPut {
6 source := req.Header.Get("Origin")
7 // If Origin is empty, try Referer
8 if source == "" {
9 source = req.Header.Get("Referer")
10 }
11 sourceUrl, _ := url.Parse(source)
12 // Check if Host and Origin/Referer match
13 if sourceUrl.Host != req.Host {
14 http.Error(w, "Cross-site launch attempt rejected", http.StatusForbidden)
15 return
16 }
17 }
18 // Process request further
19 next.ServeHTTP(w, req)
20 })
21}
This is a decent defense method, but it is not without flaws:
- Although modern browsers are required to send one of these headers for POST requests, and their absence alone could be grounds for rejecting the request, they can be stripped by proxy servers.
- In a complex infrastructure with multiple domains, maintaining a list of trusted domains can be laborious.
Sec-Fetch-* Header Verification #
In addition to Origin and Referer, it is worth analyzing the Sec-Fetch-* headers, which are added by modern browsers and cannot be spoofed using scripts within the browser.
Specifically, the Sec-Fetch-Site header can take values like same-origin, same-site, cross-site, and none.
same-origin: the request was made from the same domain, protocol, and port—the most secure level.same-site: the request was made from a subdomain of the same site (e.g., fromapi.mission-control.internaltomission-control.internal).cross-site: a request from any other site.none: the request was initiated by the user (address bar entry, bookmarks, etc.).
1// Example middleware for Sec-Fetch-Site checking
2func FetchMetadataMiddleware(next http.Handler) http.Handler {
3 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4 // Check only mutating methods
5 if r.Method == http.MethodPost || r.Method == http.MethodPut {
6 site := r.Header.Get("Sec-Fetch-Site")
7
8 // Allow only requests from our domain or direct entries
9 if site != "same-origin" && site != "same-site" && site != "none" {
10 http.Error(w, "Request blocked by Fetch Metadata policy", http.StatusForbidden)
11 return
12 }
13 }
14
15 next.ServeHTTP(w, r)
16 })
17}
Additionally, it can be useful to analyze headers like Sec-Fetch-Mode (how the request was made—link, form, script), Sec-Fetch-Dest (what is being requested—document, image, etc.), and Sec-Fetch-User (user involvement—click or automatic).
This is an excellent way to flexibly configure allowed request methods, but don't forget that, like SameSite, these headers might not be sent by older browsers.
Cryptographic Tokens (Challenge-Response) #
While all previous methods increase security, they are not without drawbacks and should only be used as supplementary defense layers.
The most reliable and universal method is adding a secret unique value (CSRF token) to every request.
Depending on whether your system is stateful (with a session) or stateless (without sessions), two different approaches can be applied:
- Synchronizer Token Pattern: The server generates a token, saves it in the user's session, and inserts it into the HTML form. Upon receiving a request, the server compares the token from the form with the token in the session.
- Double Submit Cookie: The token is sent in both a cookie and the request body. The server checks that they match without maintaining state on its side (stateless).
In the following articles, we will take a detailed look at each approach and their implementation details.