ASVS Level 1 Web Frontend Security in Node.js and Go

Series OWASP ASVS 5.0 Level 1 Part 4/8 All parts

This part is about one thing: the browser will happily do work on your behalf for a website you have never heard of. It attaches your session cookie to a request that came from someone else’s page. It renders a file your user uploaded as if you had written it. It falls back to plain HTTP because a link said so. None of that is a bug in your code. It is how browsers work, and your job is to send the handful of headers and checks that switch it off.

ASVS chapter V3 Web Frontend Security collects those controls, and eight of its requirements apply at Level 1. This part closes all eight with servers you can run and curl at. Each control is shown as a broken version next to a fixed version, usually a few lines apart.

Everything below exists in both Node.js with Fastify and Go with Fiber. Pick your stack once and the whole article follows it. Part 2 covered encoding and Part 3 covered validation. This part is the browser’s half of the job.

Conceptual Overview

Four ideas explain all eight requirements.

An origin is the scheme, the host, and the port together. https://app.example.com and http://app.example.com are different origins, because the scheme differs. So are https://app.example.com and https://app.example.com:8443. The browser’s same-origin policy stops a page on one origin from reading a response from another origin. That is the whole foundation, and it has a large hole in it.

The hole: the same-origin policy blocks reading, not sending. A page on evil.example cannot read your API’s response, but it can absolutely make the request, and the browser will attach your cookies to it. If that request changes something, the damage is already done before anyone tries to read the reply. That is cross-site request forgery, or CSRF, and it is what requirements V3.5.1 to V3.5.3 exist to stop.

CORS relaxes the same-origin policy. It never tightens it. This trips up almost everyone. Access-Control-Allow-Origin is you giving another origin permission to read your responses. There is no CORS setting that makes your API safer than it was with no CORS headers at all. Getting it wrong can only ever hand out access you did not mean to give.

Some cross-origin requests get checked first, and some do not. Before an unusual request, the browser sends a preflight: an OPTIONS request asking your server whether the real one is allowed. Requests that were already possible before CORS existed skip it, because the web cannot break them. That means GET, HEAD, and POST with a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain, and no unusual headers. In other words, exactly what an HTML form sends. A form post from any site on the internet reaches your endpoint with no preflight and no warning.

One more term. Sec-Fetch-* are request header fields the browser adds by itself and JavaScript cannot change. Sec-Fetch-Site: cross-site tells you the request came from another origin, and Sec-Fetch-Dest: image tells you the browser was loading it as an image. They are trustworthy in a way that Referer never was, and ASVS mentions them as an option in two of the requirements below.

Prerequisites

Step 1: Set Up a Scratch Project

Every control is a pair of servers named -bad and -good, both on port 3000. Run one at a time, Ctrl+C between them, and keep a second terminal open for curl.

mkdir -p ~/asvs-v3/node/uploads
cd ~/asvs-v3/node
npm init -y
npm pkg set type=module
npm install fastify @fastify/formbody

Each program is one file, started with node <name>.mjs.

mkdir -p ~/asvs-v3/go/cmd ~/asvs-v3/go/uploads
cd ~/asvs-v3/go
go mod init asvs-v3
go get github.com/gofiber/fiber/v3

Each program lives in its own folder under cmd/, started with go run ./cmd/<name>.

Step 2: Stop the Browser Running Files Your Users Uploaded

V3.2.1 Verify that security controls are in place to prevent browsers from rendering content or functionality in HTTP responses in an incorrect context (e.g., when an API, a user-uploaded file or other resource is requested directly). Possible controls could include: not serving the content unless HTTP request header fields (such as Sec-Fetch-*) indicate it is the correct context, using the sandbox directive of the Content-Security-Policy header field or using the attachment disposition type in the Content-Disposition header field.

An uploaded file served from your own domain runs with your domain’s privileges. If a user uploads an HTML file and someone opens it at https://app.example.com/uploads/notes.html, any script inside it can read app.example.com cookies and call your APIs as the logged-in user. The upload was harmless. Serving it as a web page is what did the damage.

Create the uploaded file first. This is what a malicious “notes” file looks like:

cat > uploads/notes.html <<'HTML'
<h1>Meeting notes</h1>
<img src=x onerror="alert(document.domain)">
HTML

The alert is standing in for a real payload. In a browser it prints your own domain back at you, which is the point being made.

Create upload-bad.mjs:

import Fastify from 'fastify'
import { readFile } from 'node:fs/promises'

const app = Fastify()

app.get('/uploads/notes.html', async (req, reply) => {
  const body = await readFile('./uploads/notes.html')
  return reply.type('text/html').send(body)
})

await app.listen({ port: 3000 })

upload-good.mjs changes only the headers:

import Fastify from 'fastify'
import { readFile } from 'node:fs/promises'

const app = Fastify()

app.get('/uploads/notes.html', async (req, reply) => {
  const body = await readFile('./uploads/notes.html')
  return reply
    .header('Content-Disposition', 'attachment; filename="notes.html"')
    .header('X-Content-Type-Options', 'nosniff')
    .header('Content-Security-Policy', 'sandbox')
    .type('application/octet-stream')
    .send(body)
})

await app.listen({ port: 3000 })

Create cmd/upload-bad/main.go:

package main

import (
	"log"
	"os"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Get("/uploads/notes.html", func(c fiber.Ctx) error {
		body, err := os.ReadFile("./uploads/notes.html")
		if err != nil {
			return fiber.ErrNotFound
		}
		c.Set("Content-Type", "text/html")
		return c.Send(body)
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/upload-good/main.go changes only the headers:

package main

import (
	"log"
	"os"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Get("/uploads/notes.html", func(c fiber.Ctx) error {
		body, err := os.ReadFile("./uploads/notes.html")
		if err != nil {
			return fiber.ErrNotFound
		}
		c.Set("Content-Disposition", `attachment; filename="notes.html"`)
		c.Set("X-Content-Type-Options", "nosniff")
		c.Set("Content-Security-Policy", "sandbox")
		c.Set("Content-Type", "application/octet-stream")
		return c.Send(body)
	})

	log.Fatal(app.Listen(":3000"))
}

Start each server in turn and look at the headers:

$ curl -s -i http://localhost:3000/uploads/notes.html | head -3
HTTP/1.1 200 OK
content-type: text/html

$ curl -s -i http://localhost:3000/uploads/notes.html | head -5
HTTP/1.1 200 OK
content-disposition: attachment; filename="notes.html"
x-content-type-options: nosniff
content-security-policy: sandbox
content-type: application/octet-stream

Open both in a browser. The first pops an alert box showing localhost. The second downloads a file and runs nothing.

Each header does a separate job, and you want all four. Content-Disposition: attachment tells the browser to download rather than display. Content-Type: application/octet-stream stops it treating the bytes as a document. X-Content-Type-Options: nosniff stops it overruling you by guessing from the content. Content-Security-Policy: sandbox puts the response in a unique origin with scripts disabled, so even if something does render it, it has no access to your site.

The strongest version of this control is not a header at all: serve user uploads from a separate domain, so a script that escapes is not on your origin in the first place. Headers are the version you can ship this afternoon.

Step 3: Put Text on the Page as Text

V3.2.2 Verify that content intended to be displayed as text, rather than rendered as HTML, is handled using safe rendering functions (such as createTextNode or textContent) to prevent unintended execution of content such as HTML or JavaScript.

This one lives in the browser, so it is the same code whichever backend you chose. innerHTML parses the string you give it as HTML. textContent does not.

Save this as render-bad.html and open it with ?name=<img src=x onerror="alert(1)"> on the end of the URL:

<div id="greeting"></div>
<script>
  const name = new URLSearchParams(location.search).get('name')
  document.getElementById('greeting').innerHTML = 'Hello, ' + name
</script>

render-good.html changes one word:

<div id="greeting"></div>
<script>
  const name = new URLSearchParams(location.search).get('name')
  document.getElementById('greeting').textContent = 'Hello, ' + name
</script>

The first pops an alert. The second prints the literal text Hello, <img src=x onerror="alert(1)"> on the page.

Notice the payload is an image, not a <script> tag. A <script> tag inserted with innerHTML does not run, which is why people test with one, see nothing happen, and conclude innerHTML is safe. An onerror handler on a broken image runs every time. So do onload, onfocus, and a long list of others.

The same rule shows up in every framework. React’s dangerouslySetInnerHTML, Vue’s v-html, and Angular’s [innerHTML] are this bug with a longer name; their normal interpolation is the textContent behaviour and is safe. If content genuinely must contain HTML, that is sanitization, covered in Part 2.

V3.3.1 Verify that cookies have the ‘Secure’ attribute set, and if the ‘__Host-’ prefix is not used for the cookie name, the ‘__Secure-’ prefix must be used for the cookie name.

Secure tells the browser never to send this cookie over plain HTTP. Without it, one link to http://app.example.com on a coffee shop network leaks the session, and the browser sends it before your redirect to HTTPS ever happens.

The prefix is the less familiar half. Secure controls sending. The prefix controls storing, and the browser enforces it rather than trusting your server:

  • __Secure- requires the cookie to have Secure and to have been set over HTTPS.
  • __Host- requires all that, plus Path=/, plus no Domain attribute, which means no subdomain can write it.

That last point is what matters. Without a prefix, an attacker-controlled subdomain like blog.example.com can set a cookie named session that your main site will read. The browser will not let it set __Host-session. Use __Host- unless you genuinely need the cookie shared across subdomains, and __Secure- when you do.

Create cookie-bad.mjs:

import Fastify from 'fastify'

const app = Fastify()

app.post('/login', async (req, reply) => {
  reply.header('Set-Cookie', 'session=8f14e45fceea167a; Path=/; HttpOnly')
  return { ok: true }
})

await app.listen({ port: 3000 })

cookie-good.mjs changes one line:

import Fastify from 'fastify'

const app = Fastify()

app.post('/login', async (req, reply) => {
  reply.header('Set-Cookie', '__Host-session=8f14e45fceea167a; Path=/; HttpOnly; Secure; SameSite=Lax')
  return { ok: true }
})

await app.listen({ port: 3000 })
$ curl -s -i -X POST http://localhost:3000/login | grep -i set-cookie
set-cookie: session=8f14e45fceea167a; Path=/; HttpOnly

$ curl -s -i -X POST http://localhost:3000/login | grep -i set-cookie
set-cookie: __Host-session=8f14e45fceea167a; Path=/; HttpOnly; Secure; SameSite=Lax

Create cmd/cookie-bad/main.go:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Post("/login", func(c fiber.Ctx) error {
		c.Cookie(&fiber.Cookie{
			Name:     "session",
			Value:    "8f14e45fceea167a",
			Path:     "/",
			HTTPOnly: true,
		})
		return c.JSON(fiber.Map{"ok": true})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/cookie-good/main.go changes the name and adds two fields:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Post("/login", func(c fiber.Ctx) error {
		c.Cookie(&fiber.Cookie{
			Name:     "__Host-session",
			Value:    "8f14e45fceea167a",
			Path:     "/",
			HTTPOnly: true,
			Secure:   true,
			SameSite: "Lax",
		})
		return c.JSON(fiber.Map{"ok": true})
	})

	log.Fatal(app.Listen(":3000"))
}
$ curl -s -i -X POST http://localhost:3000/login | grep -i set-cookie
Set-Cookie: session=8f14e45fceea167a; path=/; HttpOnly; SameSite=Lax

$ curl -s -i -X POST http://localhost:3000/login | grep -i set-cookie
Set-Cookie: __Host-session=8f14e45fceea167a; path=/; HttpOnly; secure; SameSite=Lax

Fiber adds SameSite=Lax by itself, which is a sensible default and not the same thing as Secure. Notice the broken version still has no Secure and no prefix.

Two practical notes. HttpOnly is not part of this requirement (it belongs to V3.3.2 at Level 2) but you should set it anyway: it stops JavaScript reading the cookie. And browsers treat http://localhost as a secure context, so Secure cookies do work while you are developing locally.

Step 5: Send HSTS on Every Response

V3.4.1 Verify that a Strict-Transport-Security header field is included on all responses to enforce an HTTP Strict Transport Security (HSTS) policy. A maximum age of at least 1 year must be defined, and for L2 and up, the policy must apply to all subdomains as well.

A user types app.example.com into the address bar. The browser tries http:// first, your server sends a redirect to https://, and everything looks fine. That first plain request is the problem: anyone on the same network can answer it before you do. HSTS closes the gap by telling the browser to never use http:// for this host again, so the redirect stops being needed at all.

Level 1 asks for a max-age of at least one year, which is 31536000 seconds. includeSubDomains is only required from Level 2, but add it now if you control every subdomain.

hsts-bad.mjs is a normal Fastify app with nothing added:

import Fastify from 'fastify'

const app = Fastify()

app.get('/', async () => ({ ok: true }))

await app.listen({ port: 3000 })

hsts-good.mjs adds a hook, so the header is on every response instead of on the routes somebody remembered:

import Fastify from 'fastify'

const app = Fastify()

app.addHook('onSend', async (req, reply) => {
  reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
})

app.get('/', async () => ({ ok: true }))

await app.listen({ port: 3000 })
$ curl -s -i http://localhost:3000/ | grep -i strict-transport
(no output)

$ curl -s -i http://localhost:3000/ | grep -i strict-transport
strict-transport-security: max-age=31536000; includeSubDomains

cmd/hsts-bad/main.go is a normal Fiber app with nothing added:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Get("/", func(c fiber.Ctx) error {
		return c.JSON(fiber.Map{"ok": true})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/hsts-good/main.go adds middleware, so the header is on every response instead of on the routes somebody remembered:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Use(func(c fiber.Ctx) error {
		c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
		return c.Next()
	})

	app.Get("/", func(c fiber.Ctx) error {
		return c.JSON(fiber.Map{"ok": true})
	})

	log.Fatal(app.Listen(":3000"))
}
$ curl -s -i http://localhost:3000/ | grep -i strict-transport
(no output)

$ curl -s -i http://localhost:3000/ | grep -i strict-transport
Strict-Transport-Security: max-age=31536000; includeSubDomains

Two things this demo cannot show you. Browsers ignore HSTS on a plain HTTP response, so on http://localhost the header arrives and does nothing; it only takes effect over HTTPS. And in most deployments the right place for this header is the reverse proxy, where it also covers static files and error pages. If you terminate TLS with Nginx, see Containerize and Deploy a Go API with Docker and Nginx on Ubuntu for where that block goes.

Start with a shorter max-age while you confirm every subdomain has a working certificate. HSTS is hard to undo: once a browser has cached the policy it refuses plain HTTP for a year, and you cannot reach in and clear it.

Step 6: Pin CORS to an Allowlist

V3.4.2 Verify that the Cross-Origin Resource Sharing (CORS) Access-Control-Allow-Origin header field is a fixed value by the application, or if the Origin HTTP request header field value is used, it is validated against an allowlist of trusted origins. When ‘Access-Control-Allow-Origin: *’ needs to be used, verify that the response does not include any sensitive information.

Somebody hits a CORS error in development, searches for a fix, and finds the snippet that echoes the request’s Origin header straight back. It works immediately, which is the problem. Echoing the origin allows every origin, including the phishing page open in another tab, and Access-Control-Allow-Credentials: true lets that page read your responses with the user’s session attached.

Create cors-bad.mjs:

import Fastify from 'fastify'

const app = Fastify()

app.addHook('onSend', async (req, reply) => {
  reply.header('Access-Control-Allow-Origin', req.headers.origin)
  reply.header('Access-Control-Allow-Credentials', 'true')
})

app.get('/me', async () => ({ email: '[email protected]' }))

await app.listen({ port: 3000 })

cors-good.mjs checks the origin against a list before echoing it:

import Fastify from 'fastify'

const app = Fastify()

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com']

app.addHook('onSend', async (req, reply) => {
  if (allowedOrigins.includes(req.headers.origin)) {
    reply.header('Access-Control-Allow-Origin', req.headers.origin)
    reply.header('Vary', 'Origin')
    reply.header('Access-Control-Allow-Credentials', 'true')
  }
})

app.get('/me', async () => ({ email: '[email protected]' }))

await app.listen({ port: 3000 })
$ curl -s -i -H 'Origin: https://evil.example' http://localhost:3000/me | grep -i access-control
access-control-allow-origin: https://evil.example
access-control-allow-credentials: true

$ curl -s -i -H 'Origin: https://evil.example' http://localhost:3000/me | grep -i access-control
(no output)

$ curl -s -i -H 'Origin: https://app.example.com' http://localhost:3000/me | grep -i 'access-control\|vary'
access-control-allow-origin: https://app.example.com
vary: Origin
access-control-allow-credentials: true

For a real application, use @fastify/cors with an explicit origin array rather than hand-rolling this. The manual version is here so you can see exactly which header does what.

Create cmd/cors-bad/main.go:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Use(func(c fiber.Ctx) error {
		c.Set("Access-Control-Allow-Origin", c.Get("Origin"))
		c.Set("Access-Control-Allow-Credentials", "true")
		return c.Next()
	})

	app.Get("/me", func(c fiber.Ctx) error {
		return c.JSON(fiber.Map{"email": "[email protected]"})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/cors-good/main.go checks the origin against a list before echoing it:

package main

import (
	"log"
	"slices"

	"github.com/gofiber/fiber/v3"
)

var allowedOrigins = []string{"https://app.example.com", "https://admin.example.com"}

func main() {
	app := fiber.New()

	app.Use(func(c fiber.Ctx) error {
		if origin := c.Get("Origin"); slices.Contains(allowedOrigins, origin) {
			c.Set("Access-Control-Allow-Origin", origin)
			c.Set("Vary", "Origin")
			c.Set("Access-Control-Allow-Credentials", "true")
		}
		return c.Next()
	})

	app.Get("/me", func(c fiber.Ctx) error {
		return c.JSON(fiber.Map{"email": "[email protected]"})
	})

	log.Fatal(app.Listen(":3000"))
}
$ curl -s -i -H 'Origin: https://evil.example' http://localhost:3000/me | grep -i access-control
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true

$ curl -s -i -H 'Origin: https://evil.example' http://localhost:3000/me | grep -i access-control
(no output)

$ curl -s -i -H 'Origin: https://app.example.com' http://localhost:3000/me | grep -i 'Access-Control\|Vary'
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Access-Control-Allow-Credentials: true

For a real application, use Fiber’s cors middleware with an explicit AllowOrigins list rather than hand-rolling this. The manual version is here so you can see exactly which header does what.

Vary: Origin is not decoration. Without it, a cache in front of your service can store the response it built for app.example.com and hand the same Access-Control-Allow-Origin header to the next origin that asks.

Two more traps. Matching origins with startsWith or a regular expression is how https://app.example.com.evil.example gets allowed, so compare the whole string. And a wildcard is fine for a public price list and never fine for anything tied to a user, which is why browsers refuse to combine * with Access-Control-Allow-Credentials: true.

Step 7: Refuse Cross-Site Requests to Sensitive Endpoints

V3.5.1 Verify that, if the application does not rely on the CORS preflight mechanism to prevent disallowed cross-origin requests to use sensitive functionality, these requests are validated to ensure they originate from the application itself. This may be done by using and validating anti-forgery tokens or requiring extra HTTP header fields that are not CORS-safelisted request-header fields. This is to defend against browser-based request forgery attacks, commonly known as cross-site request forgery (CSRF).

V3.5.2 Verify that, if the application relies on the CORS preflight mechanism to prevent disallowed cross-origin use of sensitive functionality, it is not possible to call the functionality with a request which does not trigger a CORS-preflight request. This may require checking the values of the ‘Origin’ and ‘Content-Type’ request header fields or using an extra header field that is not a CORS-safelisted header-field.

These two requirements are one decision with two branches. Either you check the request yourself, or you make sure it cannot arrive without a preflight. In practice you do both.

Here is the attack. Save it as evil.html and serve it from another port, which makes it a different origin:

<h1>Free holiday</h1>
<form id="f" action="http://localhost:3000/account/email" method="POST">
  <input type="hidden" name="email" value="[email protected]">
</form>
<script>document.getElementById('f').submit()</script>

Serve it with python3 -m http.server 4001, then open http://localhost:4001/evil.html. It is an ordinary form post, so there is no preflight and no CORS error. The user sees a page about a holiday.

Create csrf-bad.mjs. The @fastify/formbody plugin is what a server-rendered application registers so that HTML forms work:

import Fastify from 'fastify'
import formbody from '@fastify/formbody'

const app = Fastify()
app.register(formbody)

app.post('/account/email', async (req) => {
  return { changed: true, email: req.body.email }
})

await app.listen({ port: 3000 })

csrf-good.mjs drops the form parser and checks where the request came from:

import Fastify from 'fastify'

const app = Fastify()

const siteOrigin = 'http://localhost:3000'

app.post('/account/email', async (req, reply) => {
  if (req.headers.origin !== siteOrigin) {
    return reply.code(403).send({ error: 'cross-origin request refused' })
  }
  return { changed: true, email: req.body.email }
})

await app.listen({ port: 3000 })

Run the attack page against each server. Against the broken one:

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: https://evil.example' \
    -d '[email protected]'
{"changed":true,"email":"[email protected]"}

Against the fixed one, the same attack gets stopped twice over:

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: http://localhost:3000' \
    -d '[email protected]'
{"statusCode":415,"code":"FST_ERR_CTP_INVALID_MEDIA_TYPE","error":"Unsupported Media Type","message":"Unsupported Media Type"}

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: https://evil.example' \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]"}'
{"error":"cross-origin request refused"}

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: http://localhost:3000' \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]"}'
{"changed":true,"email":"[email protected]"}

The 415 is V3.5.2 doing its job. Without the form parser, Fastify accepts only JSON, and a JSON body forces the browser to send a preflight, which the Origin check then refuses. That is why evil.html gets a 415 in the browser rather than reaching your handler.

Create cmd/csrf-bad/main.go. Fiber’s binder reads the Content-Type and handles form posts automatically, which is convenient and is exactly the hole:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

type EmailChange struct {
	Email string `json:"email" form:"email"`
}

func main() {
	app := fiber.New()

	app.Post("/account/email", func(c fiber.Ctx) error {
		var in EmailChange
		if err := c.Bind().Body(&in); err != nil {
			return fiber.ErrBadRequest
		}
		return c.JSON(fiber.Map{"changed": true, "email": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/csrf-good/main.go checks where the request came from and refuses body types that skip the preflight:

package main

import (
	"log"
	"strings"

	"github.com/gofiber/fiber/v3"
)

const siteOrigin = "http://localhost:3000"

type EmailChange struct {
	Email string `json:"email"`
}

func main() {
	app := fiber.New()

	app.Post("/account/email", func(c fiber.Ctx) error {
		if c.Get("Origin") != siteOrigin {
			return c.Status(403).JSON(fiber.Map{"error": "cross-origin request refused"})
		}
		if !strings.HasPrefix(c.Get("Content-Type"), "application/json") {
			return c.Status(415).JSON(fiber.Map{"error": "JSON body required"})
		}
		var in EmailChange
		if err := c.Bind().Body(&in); err != nil {
			return fiber.ErrBadRequest
		}
		return c.JSON(fiber.Map{"changed": true, "email": in.Email})
	})

	log.Fatal(app.Listen(":3000"))
}

Run the attack page against each server. Against the broken one:

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: https://evil.example' \
    -d '[email protected]'
{"changed":true,"email":"[email protected]"}

Against the fixed one, the same attack gets stopped twice over:

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: https://evil.example' \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]"}'
{"error":"cross-origin request refused"}

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: http://localhost:3000' \
    -d '[email protected]'
{"error":"JSON body required"}

$ curl -s -X POST http://localhost:3000/account/email \
    -H 'Origin: http://localhost:3000' \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]"}'
{"changed":true,"email":"[email protected]"}

The Origin check is V3.5.1. The Content-Type check is V3.5.2: a JSON body forces the browser to send a preflight, and refusing form bodies removes the path that skips it. Fiber will not add that second check for you, because binding form data is a feature.

Three notes before you copy this into a real service.

Do not use Referer instead. It gets stripped by privacy settings and proxies, so you end up allowing requests with no Referer at all, which is every request an attacker cares to send. Origin is sent on every cross-origin request and on every POST.

SameSite=Lax on the session cookie is a strong second layer, and you set it in Step 4. It is not a replacement: it does nothing about a same-site subdomain.

For server-rendered forms, use a token. The Origin check suits JSON APIs. A form application wants an anti-forgery token: a random value stored in the session, rendered into a hidden field, and compared on submit. Both are named in the requirement, and both pass.

Step 8: Use a Method That Says Something Changes

V3.5.3 Verify that HTTP requests to sensitive functionality use appropriate HTTP methods such as POST, PUT, PATCH, or DELETE, and not methods defined by the HTTP specification as “safe” such as HEAD, OPTIONS, or GET. Alternatively, strict validation of the Sec-Fetch-* request header fields can be used to ensure that the request did not originate from an inappropriate cross-origin call, a navigation request, or a resource load (such as an image source) where this is not expected.

GET is defined as a safe method, so the whole web assumes it changes nothing. Browsers prefetch links, mail clients fetch previews, crawlers follow everything. Put a delete behind a GET and any of those can fire it, and a one-line <img> tag on someone else’s page becomes an attack no CSRF token can help with.

Create method-bad.mjs:

import Fastify from 'fastify'

const app = Fastify()

app.get('/account/delete', async () => {
  return { deleted: true }
})

await app.listen({ port: 3000 })

method-good.mjs changes the verb and the path:

import Fastify from 'fastify'

const app = Fastify()

app.delete('/account', async () => {
  return { deleted: true }
})

await app.listen({ port: 3000 })
$ curl -s http://localhost:3000/account/delete
{"deleted":true}

$ curl -s http://localhost:3000/account
{"message":"Route GET:/account not found","error":"Not Found","statusCode":404}
$ curl -s -X DELETE http://localhost:3000/account
{"deleted":true}

Create cmd/method-bad/main.go:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Get("/account/delete", func(c fiber.Ctx) error {
		return c.JSON(fiber.Map{"deleted": true})
	})

	log.Fatal(app.Listen(":3000"))
}

cmd/method-good/main.go changes the verb and the path:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"
)

func main() {
	app := fiber.New()

	app.Delete("/account", func(c fiber.Ctx) error {
		return c.JSON(fiber.Map{"deleted": true})
	})

	log.Fatal(app.Listen(":3000"))
}
$ curl -s http://localhost:3000/account/delete
{"deleted":true}

$ curl -s http://localhost:3000/account
Method Not Allowed
$ curl -s -X DELETE http://localhost:3000/account
{"deleted":true}

The requirement offers a second route: check Sec-Fetch-Site and Sec-Fetch-Dest instead. Refusing a request whose Sec-Fetch-Dest is image closes the <img> trick even on a GET, and those headers cannot be forged from JavaScript. Use it as an extra layer, or for an old GET endpoint you cannot change yet.

Grep your routes for delete, remove, approve, cancel, pay, or logout next to a GET. Logout is the one nearly every application gets wrong.

Common Mistakes and Troubleshooting

Thinking CORS protects your API. It hands out permission to read responses. A missing CORS header never stopped a request from arriving and being processed. Steps 7 and 8 are the ones that stop that.

Echoing the Origin header because it made the error go away. It allows every site on the internet. If you needed a quick fix in development, use an allowlist with your development origin in it.

Testing innerHTML with a <script> tag, seeing nothing, and moving on. Scripts inserted that way do not run. <img src=x onerror=...> does. Test with that.

Setting HSTS on the login route only. The requirement says all responses. Use a hook or middleware.

Renaming the cookie to __Host-session while keeping a Domain attribute. The browser silently refuses to store it and the user cannot log in. __Host- means Path=/, Secure, and no Domain at all.

Assuming an API that only accepts JSON is safe from CSRF. It usually is, but only because a JSON body forces a preflight. Register a form-body parser, or set your framework to accept text/plain, and the protection disappears without a line of your code changing.

Best Practices

Set the security headers in one place. A single hook or middleware, or the reverse proxy config, covers routes that do not exist yet. Anything set per-route will be missing from the route somebody adds next month.

Serve user uploads from a separate domain. Headers are the fix you ship today. A different origin keeps working when someone adds a new upload path and forgets.

Turn each curl in this article into a test. The origin echo, the missing HSTS header, the cookie without Secure, the cross-origin form post, and the GET that deletes something are five assertions that fail loudly if a header gets dropped in a refactor.

Add a Content Security Policy. Not a Level 1 requirement, but a policy without unsafe-inline turns many of the failures above into console errors instead of incidents.

Conclusion

You have closed all eight Level 1 requirements in V3. Uploaded files download instead of executing, text goes onto the page as text, the session cookie is Secure and carries a __Host- prefix, HSTS goes out on every response, CORS answers only origins you listed, sensitive endpoints refuse requests from other sites, and nothing important happens behind a GET.

Look at how small the fixes are. Four headers, one word (textContent), one cookie name, one middleware, one list check, one if, and one verb. Every one is a default the browser will not choose for you.

Mark V3.2.1 through V3.5.3 as passed in your own record, and note where each header is set. The answer is usually “somewhere in the proxy config”, which is exactly what you will have forgotten by the next review.

The next part covers V4 API and Web Service and its two Level 1 requirements: sending a Content-Type that matches what you actually sent, and refusing to run WebSockets over anything but TLS.

The OWASP Application Security Verification Standard is licensed under Creative Commons Attribution-ShareAlike 4.0, which is what permits the requirement text to be reproduced here.

All tutorials →

Latest Tutorials

Support this site