ASVS Level 1 File Handling in Node.js and Go

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

A file upload is the only feature where you let a stranger put bytes on your disk and then hand them back out over HTTP. ASVS chapter V5 File Handling has four Level 1 requirements, and each one covers a different question you have to answer between the moment a user picks a file and the moment your server stores it: how big is it, what is actually in it, where does it land, and what is it called.

Get the last two wrong and the result is remote code execution, which means an attacker runs commands on your server. That is not a theoretical outcome. Step 4 of this article does it in a browser tab against a stock Nginx and PHP setup, and the fix is four lines of configuration.

Everything below exists in both Node.js with Fastify and Go with Fiber. Pick your stack once and the whole article follows it. Each control is a broken version next to a fixed version, usually one line apart.

Conceptual Overview

The file extension is a claim made by whoever uploaded the file. The .png on the end of avatar.png is part of a string the browser sent you. So is the Content-Type header attached to that part of the upload. Neither one is checked by anything before it reaches your code. A file named avatar.png containing PHP source code is still PHP source code.

Magic bytes are the closest thing to a fact. Most binary formats start with a fixed sequence of bytes that identifies them. A PNG file always starts with the eight bytes 89 50 4E 47 0D 0A 1A 0A. A JPEG always starts with FF D8 FF. Reading those bytes and comparing them against what the extension claims is a cheap check that catches the file that lied about what it is.

Your application does not execute uploaded files, but the server in front of it might. Node.js and Go both treat an uploaded file as bytes. They will never run it. The danger is that on a typical Ubuntu host, Nginx or Apache is also configured to hand certain files to a script interpreter, and if your uploads directory sits inside the web root, that interpreter will happily run whatever landed there.

Path traversal is what happens when user input becomes part of a filesystem path. The sequence ../ means “go up one directory”. If a download endpoint builds a path by joining a base directory with a value from the URL, then ?id=../../../../etc/passwd walks out of your uploads folder and reads a system file. The same trick works on the write side if the stored filename comes from the user.

A file is not just a file, it is also a name, a size, and a location. Three of the four Level 1 requirements are about the last three, not the contents. That is worth remembering when you are tempted to solve everything with a virus scanner.

Prerequisites

Step 1: Set Up a Scratch Project

Every control below is a pair of servers named -bad and -good, both listening on port 3000. Run one at a time and use Ctrl+C between them.

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

Each program is one file, started with node <name>.mjs. You also need two test files, one real image and one that only claims to be one:

cd ~/asvs-v5/node
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' | base64 -d > real.png
printf '<?php echo shell_exec($_GET["c"]); ?>\n' > shell.png
head -c 5242880 /dev/urandom > big.bin
file real.png shell.png
real.png:  PNG image data, 1 x 1, 8-bit/color RGBA, non-interlaced
shell.png: PHP script, ASCII text
mkdir -p ~/asvs-v5/go/cmd ~/asvs-v5/go/uploads
cd ~/asvs-v5/go
go mod init asvs-v5
go get github.com/gofiber/fiber/v3 github.com/google/uuid

Each program lives in its own folder under cmd/, started with go run ./cmd/<name>. You also need two test files, one real image and one that only claims to be one:

cd ~/asvs-v5/go
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' | base64 -d > real.png
printf '<?php echo shell_exec($_GET["c"]); ?>\n' > shell.png
head -c 5242880 /dev/urandom > big.bin
file real.png shell.png
real.png:  PNG image data, 1 x 1, 8-bit/color RGBA, non-interlaced
shell.png: PHP script, ASCII text

The shell.png file is a real PHP script with a .png name. Nothing in this article makes it dangerous on its own. Step 4 shows the one configuration that does.

Step 2: Cap the Size of Every Upload

V5.2.1 Verify that the application will only accept files of a size which it can process without causing a loss of performance or a denial of service attack.

Both frameworks ship a safe default here, so this requirement is usually failed by someone raising the limit rather than by nobody setting one. The sequence is always the same: a user reports that a legitimate upload fails with 413 Payload Too Large, and the quickest way to make the complaint go away is to type a much bigger number.

Pick the size from what your server can actually process, not from the largest file anyone has ever tried to send. An avatar is 2 MB. A profile photo does not become a 500 MB video because one user has an unusual camera.

Create size-bad.mjs:

import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { writeFile } from 'node:fs/promises'

const app = Fastify()
await app.register(multipart, { limits: { fileSize: 500 * 1024 * 1024 } })

app.post('/avatar', async (req) => {
  const part = await req.file()
  const body = await part.toBuffer()
  await writeFile('uploads/avatar.bin', body)
  return { stored: part.filename, bytes: body.length }
})

await app.listen({ port: 3000 })

size-good.mjs changes the number:

import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { writeFile } from 'node:fs/promises'

const app = Fastify()
await app.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 } })

app.post('/avatar', async (req) => {
  const part = await req.file()
  const body = await part.toBuffer()
  await writeFile('uploads/avatar.bin', body)
  return { stored: part.filename, bytes: body.length }
})

await app.listen({ port: 3000 })

Send the 5 MB file to each one:

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"stored":"big.bin","bytes":5242880} [200]

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"statusCode":413,"code":"FST_REQ_FILE_TOO_LARGE","error":"Payload Too Large","message":"request file too large"} [413]

The 413 comes from part.toBuffer(), which throws when the limit is hit. If you stream the upload to disk yourself instead, the limit does not throw: the stream simply stops early and sets part.file.truncated to true, leaving you with a half-written file and a 200 response. Check that flag if you stream.

Create cmd/size-bad/main.go:

package main

import (
	"log"

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

func main() {
	app := fiber.New(fiber.Config{BodyLimit: 500 * 1024 * 1024})

	app.Post("/avatar", func(c fiber.Ctx) error {
		file, err := c.FormFile("file")
		if err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "no file"})
		}
		if err := c.SaveFile(file, "uploads/avatar.bin"); err != nil {
			return err
		}
		return c.JSON(fiber.Map{"stored": file.Filename})
	})

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

cmd/size-good/main.go changes the number:

package main

import (
	"log"

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

func main() {
	app := fiber.New(fiber.Config{BodyLimit: 2 * 1024 * 1024})

	app.Post("/avatar", func(c fiber.Ctx) error {
		file, err := c.FormFile("file")
		if err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "no file"})
		}
		if err := c.SaveFile(file, "uploads/avatar.bin"); err != nil {
			return err
		}
		return c.JSON(fiber.Map{"stored": file.Filename})
	})

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

Send the 5 MB file to each one:

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"stored":"big.bin"} [200]

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
Request Entity Too Large [413]

BodyLimit applies to the whole request body, not to one file, and Fiber rejects it before your handler runs. Note that any value of 0 or less is replaced by Fiber’s default of 4 MB, so you cannot accidentally turn the limit off.

There is a second size to worry about that no body limit catches. A zip archive of 42 KB can expand to several gigabytes, because the same compressed block can be referenced over and over. If you unpack archives, cap the total uncompressed size and the number of entries as you extract, and stop when either is exceeded.

Step 3: Check That the Bytes Match the Extension

V5.2.2 Verify that when the application accepts a file, either on its own or within an archive such as a zip file, it checks if the file extension matches an expected file extension and validates that the contents correspond to the type represented by the extension. This includes, but is not limited to, checking the initial ‘magic bytes’, performing image re-writing, and using specialized libraries for file content validation. For L1, this can focus just on files which are used to make specific business or security decisions. For L2 and up, this must apply to all files being accepted.

Notice the requirement asks for two checks, not one. The extension must be one you expect, and the contents must match that extension. Most upload code does the first half and stops, which is exactly what shell.png is designed to walk through.

Create type-bad.mjs, which checks the extension only:

import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { writeFile } from 'node:fs/promises'
import { extname } from 'node:path'

const ALLOWED = ['.png', '.jpg']

const app = Fastify()
await app.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 } })

app.post('/avatar', async (req, reply) => {
  const part = await req.file()
  const body = await part.toBuffer()
  const ext = extname(part.filename).toLowerCase()

  if (!ALLOWED.includes(ext)) {
    return reply.code(415).send({ error: 'unsupported file type' })
  }

  await writeFile(`uploads/avatar${ext}`, body)
  return { stored: part.filename, bytes: body.length }
})

await app.listen({ port: 3000 })

type-good.mjs turns the allowlist into a map from extension to the bytes that extension must start with, and compares:

import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { writeFile } from 'node:fs/promises'
import { extname } from 'node:path'

const ALLOWED = {
  '.png': Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  '.jpg': Buffer.from([0xff, 0xd8, 0xff])
}

const app = Fastify()
await app.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 } })

app.post('/avatar', async (req, reply) => {
  const part = await req.file()
  const body = await part.toBuffer()
  const ext = extname(part.filename).toLowerCase()
  const magic = ALLOWED[ext]

  if (!magic || !body.subarray(0, magic.length).equals(magic)) {
    return reply.code(415).send({ error: 'unsupported file type' })
  }

  await writeFile(`uploads/avatar${ext}`, body)
  return { stored: part.filename, bytes: body.length }
})

await app.listen({ port: 3000 })

Send the real image and the fake one to each server:

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"stored":"real.png","bytes":70} [200]
$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"stored":"shell.png","bytes":29} [200]
$ head -c 40 uploads/avatar.png
<?php echo shell_exec($_GET["c"]); ?>

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"stored":"real.png","bytes":70} [200]
$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"error":"unsupported file type"} [415]

Looking up the magic bytes by extension is what makes this a real check. Detecting the type on its own and accepting anything in a list would let a JPEG be stored as avatar.png, and the mismatch is the signal you care about.

Create cmd/type-bad/main.go, which checks the extension only:

package main

import (
	"io"
	"log"
	"os"
	"path/filepath"
	"slices"
	"strings"

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

var allowed = []string{".png", ".jpg"}

func main() {
	app := fiber.New(fiber.Config{BodyLimit: 2 * 1024 * 1024})

	app.Post("/avatar", func(c fiber.Ctx) error {
		header, err := c.FormFile("file")
		if err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "no file"})
		}
		file, err := header.Open()
		if err != nil {
			return err
		}
		defer file.Close()
		body, err := io.ReadAll(file)
		if err != nil {
			return err
		}
		ext := strings.ToLower(filepath.Ext(header.Filename))

		if !slices.Contains(allowed, ext) {
			return c.Status(415).JSON(fiber.Map{"error": "unsupported file type"})
		}

		if err := os.WriteFile("uploads/avatar"+ext, body, 0o644); err != nil {
			return err
		}
		return c.JSON(fiber.Map{"stored": header.Filename, "bytes": len(body)})
	})

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

cmd/type-good/main.go turns the allowlist into a map from extension to the media type that extension must contain, and compares it against what the bytes say. http.DetectContentType is in the standard library and reads the magic bytes for you:

package main

import (
	"io"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"

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

var allowed = map[string]string{".png": "image/png", ".jpg": "image/jpeg"}

func main() {
	app := fiber.New(fiber.Config{BodyLimit: 2 * 1024 * 1024})

	app.Post("/avatar", func(c fiber.Ctx) error {
		header, err := c.FormFile("file")
		if err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "no file"})
		}
		file, err := header.Open()
		if err != nil {
			return err
		}
		defer file.Close()
		body, err := io.ReadAll(file)
		if err != nil {
			return err
		}
		ext := strings.ToLower(filepath.Ext(header.Filename))
		detected, _, _ := strings.Cut(http.DetectContentType(body), ";")

		if want, ok := allowed[ext]; !ok || want != detected {
			return c.Status(415).JSON(fiber.Map{"error": "unsupported file type"})
		}

		if err := os.WriteFile("uploads/avatar"+ext, body, 0o644); err != nil {
			return err
		}
		return c.JSON(fiber.Map{"stored": header.Filename, "bytes": len(body)})
	})

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

Send the real image and the fake one to each server:

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"bytes":70,"stored":"real.png"} [200]
$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"bytes":29,"stored":"shell.png"} [200]
$ head -c 40 uploads/avatar.png
<?php echo shell_exec($_GET["c"]); ?>

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"bytes":70,"stored":"real.png"} [200]
$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/avatar
{"error":"unsupported file type"} [415]

http.DetectContentType returns a full media type with parameters, such as text/plain; charset=utf-8, so the strings.Cut call trims it to the part you can compare. Looking up the expected type by extension is what makes this a real check: detecting the type on its own and accepting anything in a list would let a JPEG be stored as avatar.png, and the mismatch is the signal you care about.

Magic bytes prove the file starts like a PNG. They do not prove the rest of it is a valid PNG, and a file can be both a valid image and a valid script at the same time. For images that end up in a public folder, the stronger option is re-encoding: decode the image and write a new one from the decoded pixels. Anything that was not pixel data does not survive the round trip. The requirement lists that as image re-writing, and it is worth doing when your users upload avatars.

Step 4: Store Uploads Where Nothing Can Execute Them

V5.3.1 Verify that files uploaded or generated by untrusted input and stored in a public folder, are not executed as server-side program code when accessed directly with an HTTP request.

This step has no Node.js or Go version, because neither one is where the problem lives. Fastify and Fiber serve an uploaded file as bytes and will never run it. The failure happens in the web server sitting in front of your application, and it happens the same way whichever stack you chose.

Assume Step 3 has a bug and shell.png was saved as shell.php. That is a fair assumption: allowlists get edited, an endpoint gets added by someone who did not read this article, and a library gets upgraded. The question this requirement asks is what happens next.

Here is an Nginx server block of the kind that ships in every PHP tutorial, with the uploads directory inside the web root:

server {
    listen 80;
    root /var/www/html;
    index index.html;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The location ~ \.php$ block matches any request path ending in .php, wherever it is. /uploads/shell.php ends in .php, so Nginx hands it to the PHP interpreter and the interpreter runs it:

$ curl -s 'http://localhost/uploads/shell.php?c=id'
uid=33(www-data) gid=33(www-data) groups=33(www-data)

That is an attacker running shell commands as www-data on your server, through a form that was meant to accept avatars.

The fix is a location block that claims the uploads path before the PHP rule can see it:

server {
    listen 80;
    root /var/www/html;
    index index.html;

    location ^~ /uploads/ {
        types { }
        default_type application/octet-stream;
        add_header Content-Disposition "attachment" always;
        add_header X-Content-Type-Options "nosniff" always;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

Four things are happening there. The ^~ modifier tells Nginx that when this prefix matches, it must not go on to test the regular expression locations, which is what takes the .php handler out of play. The empty types { } block plus default_type makes every file in that directory come back as a generic binary download instead of whatever its extension suggests. The two headers are the ones from Part 4, and they stop the browser treating an uploaded HTML file as a page on your origin.

Reload and try the same URL:

$ sudo nginx -t && sudo systemctl reload nginx
$ curl -s 'http://localhost/uploads/shell.php?c=id'
<?php echo shell_exec($_GET["c"]); ?>

The bytes come back instead of running. The verification for this requirement is exactly that command: upload something with a script extension, fetch it directly, and confirm you get source text rather than output.

Better still, do not put uploads under the web root at all. Store them somewhere like /var/lib/myapp/uploads, which no web server is configured to serve, and hand them out through an application route that reads the file and sends it. Then there is no directory for a misconfiguration to expose, and you get to run your authorization checks on the way past. Object storage, such as an S3 compatible bucket on a different hostname, gives you the same separation.

Step 5: Name Files Yourself

V5.3.2 Verify that when the application creates file paths for file operations, instead of user-submitted filenames, it uses internally generated or trusted data, or if user-submitted filenames or file metadata must be used, strict validation and sanitization must be applied. This is to protect against path traversal, local or remote file inclusion (LFI, RFI), and server-side request forgery (SSRF) attacks.

The requirement offers two options and they are not equal. Generating the name yourself is a property of the code that you can see at a glance. Validating a user-submitted name is a filter you have to get right against every encoding trick, and you will be maintaining it forever. Take the first option.

Start with something worth knowing: neither framework lets a traversal sequence through the upload filename. Both @fastify/multipart and Go’s mime/multipart reduce the filename to its last path component before you ever see it, so a part declaring filename="../../../../tmp/pwned.txt" arrives as pwned.txt. That is one attack you do not have to defend against.

The read side is wide open, though, and that is where this pair lives.

Create path-bad.mjs, which stores under the name the user sent and reads back whatever id the URL asks for:

import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'

const app = Fastify()
await app.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 } })

app.post('/files', async (req) => {
  const part = await req.file()
  await writeFile(join('uploads', part.filename), await part.toBuffer())
  return { id: part.filename }
})

app.get('/files', async (req, reply) => {
  return reply.send(await readFile(join('uploads', req.query.id)))
})

await app.listen({ port: 3000 })

path-good.mjs gives every upload an identifier that the server generates, keeps the original name in a lookup table as plain metadata, and refuses any identifier it did not issue:

import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { readFile, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'

const originalNames = new Map()

const app = Fastify()
await app.register(multipart, { limits: { fileSize: 2 * 1024 * 1024 } })

app.post('/files', async (req) => {
  const part = await req.file()
  const id = randomUUID()
  originalNames.set(id, part.filename)
  await writeFile(join('uploads', id), await part.toBuffer())
  return { id }
})

app.get('/files', async (req, reply) => {
  if (!originalNames.has(req.query.id)) {
    return reply.code(404).send({ error: 'not found' })
  }
  return reply.send(await readFile(join('uploads', req.query.id)))
})

await app.listen({ port: 3000 })

Upload a file, then ask for something that is not a file you uploaded:

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/files
{"id":"real.png"} [200]
$ curl -s "http://localhost:3000/files?id=../../../../etc/passwd" | head -3
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/files
{"id":"6fdad782-e483-4380-b7cc-c5f9b2371eaf"} [200]
$ curl -s -w " [%{http_code}]\n" "http://localhost:3000/files?id=../../../../etc/passwd"
{"error":"not found"} [404]

The fixed version never reaches the filesystem for an identifier it did not issue, because the Map lookup fails first. In a real application that table is a database row holding the identifier, the original filename, the owner, and the media type, which is also where your authorization check goes.

Create cmd/path-bad/main.go, which stores under the name the user sent and reads back whatever id the URL asks for:

package main

import (
	"log"
	"os"
	"path/filepath"

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

func main() {
	app := fiber.New(fiber.Config{BodyLimit: 2 * 1024 * 1024})

	app.Post("/files", func(c fiber.Ctx) error {
		header, err := c.FormFile("file")
		if err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "no file"})
		}
		if err := c.SaveFile(header, filepath.Join("uploads", header.Filename)); err != nil {
			return err
		}
		return c.JSON(fiber.Map{"id": header.Filename})
	})

	app.Get("/files", func(c fiber.Ctx) error {
		body, err := os.ReadFile(filepath.Join("uploads", c.Query("id")))
		if err != nil {
			return c.Status(404).JSON(fiber.Map{"error": "not found"})
		}
		return c.Send(body)
	})

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

cmd/path-good/main.go gives every upload an identifier that the server generates, keeps the original name in a lookup table as plain metadata, and refuses any identifier it did not issue:

package main

import (
	"log"
	"os"
	"path/filepath"

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

var originalNames = map[string]string{}

func main() {
	app := fiber.New(fiber.Config{BodyLimit: 2 * 1024 * 1024})

	app.Post("/files", func(c fiber.Ctx) error {
		header, err := c.FormFile("file")
		if err != nil {
			return c.Status(400).JSON(fiber.Map{"error": "no file"})
		}
		id := uuid.NewString()
		originalNames[id] = header.Filename
		if err := c.SaveFile(header, filepath.Join("uploads", id)); err != nil {
			return err
		}
		return c.JSON(fiber.Map{"id": id})
	})

	app.Get("/files", func(c fiber.Ctx) error {
		if _, ok := originalNames[c.Query("id")]; !ok {
			return c.Status(404).JSON(fiber.Map{"error": "not found"})
		}
		body, err := os.ReadFile(filepath.Join("uploads", c.Query("id")))
		if err != nil {
			return c.Status(404).JSON(fiber.Map{"error": "not found"})
		}
		return c.Send(body)
	})

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

Upload a file, then ask for something that is not a file you uploaded:

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/files
{"id":"real.png"} [200]
$ curl -s "http://localhost:3000/files?id=../../../../etc/passwd" | head -3
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

$ curl -s -w " [%{http_code}]\n" -F [email protected] http://localhost:3000/files
{"id":"3972c57d-4606-4fb3-a864-7d4a494b4078"} [200]
$ curl -s -w " [%{http_code}]\n" "http://localhost:3000/files?id=../../../../etc/passwd"
{"error":"not found"} [404]

The fixed version never reaches the filesystem for an identifier it did not issue, because the map lookup fails first. In a real application that map is a database row holding the identifier, the original filename, the owner, and the media type, which is also where your authorization check goes.

The example uses a query parameter deliberately. A route parameter such as /files/:id behaves differently in Fiber: c.Params hands you the value still percent-encoded, so %2f stays as the literal text %2f and never becomes a directory separator. Query values are decoded for you. Do not rely on that difference, since one url.QueryUnescape call added later removes it.

The same rule covers the two other attacks named in the requirement. Local and remote file inclusion is this bug pointed at code loading rather than file reading. Server-side request forgery is this bug where the path is a URL: if your application fetches ?url=... on the user’s behalf, an attacker sends it to http://169.254.169.254/, the cloud metadata address, and reads your instance credentials. Same shape, same fix, which is that the untrusted value selects from a set you control instead of becoming the target.

Common Mistakes and Troubleshooting

Trusting the Content-Type of the upload part. Every multipart part carries its own media type, and it is set by the client. part.mimetype in Fastify and header.Header.Get("Content-Type") in Go are both attacker-controlled strings. They are useful for logging and useless for deciding.

Blocking a list of dangerous extensions instead of allowing a short list of safe ones. A denylist has to enumerate .php, .php5, .phtml, .phar, .cgi, .jsp, .asp, and whatever the next interpreter is called. An allowlist of .png and .jpg is complete by construction.

Streaming an upload to disk and never checking whether it was truncated. This is the Fastify trap from Step 2. The file size limit fires, the write ends early, and the response is still 200. You get corrupt files and no error.

Serving uploads from your own origin without the download headers. No script execution happens, but an uploaded HTML file still runs JavaScript with your site’s cookies. That is requirement V3.2.1, covered in Part 4, and it applies to every stack.

Unpacking archives before checking what is in them. Both the total uncompressed size and the entry paths inside a zip are attacker-controlled. An entry named ../../etc/cron.d/backdoor is the traversal from Step 5 with the extraction library doing the writing.

Forgetting that generated files count. The requirement says “uploaded or generated by untrusted input”. A PDF invoice or an exported CSV built from user data lands in the same directory and needs the same treatment.

Best Practices

Store uploads outside the web root and serve them through a route. It removes an entire class of server misconfiguration and gives you a place to check who is allowed to read the file.

Give every file a random identifier and keep the original name as metadata. Show the user holiday photo.jpg in the interface, store the bytes as a UUID, and put the two in the same database row.

Re-encode images rather than validating them. Decoding a JPEG and writing a fresh one drops every byte that was not pixel data, including the metadata sections that people hide payloads in.

Set the limit in two places. Nginx has client_max_body_size, which defaults to 1 MB, and your application has its own. The proxy limit stops a large body before it reaches your process, which is the point.

Write uploads to a filesystem mounted with noexec. A separate partition for /var/lib/myapp/uploads mounted noexec,nosuid,nodev means the kernel refuses to run anything there, no matter what the web server was told to do.

Scan files if the users are strangers to each other. ClamAV via clamdscan will not catch a targeted payload, but a file-sharing feature where users hand each other documents should not be passing around known malware.

Conclusion

You have closed all four Level 1 requirements in V5. Uploads are capped at a size your server can handle, their contents have to match the extension they claim, the directory they land in cannot execute anything, and the names on disk come from your code rather than from a stranger.

The one experiment to repeat on your own systems is Step 4. Put a harmless script with a .php extension in your uploads directory, fetch it over HTTP, and look at what comes back. If it is source text, that requirement is passed and you have the evidence to write down.

Mark V5.2.1, V5.2.2, V5.3.1, and V5.3.2 as passed in your own record, or mark the chapter not applicable with a note if your application accepts no files at all.

The next two parts cover V6 Authentication, which has thirteen Level 1 requirements, more than any other chapter. Part 7 takes the eight that are about passwords, and Part 8 takes the rest: rate limiting, default accounts, activation codes, and account recovery.

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