JavaRush /Courses /Go SELF /fs.ValidPath and protection against path traversal

fs.ValidPath and protection against path traversal

Go SELF
Level 43, Lesson 3
Available

1. Why a “user-provided path” is not just a string

First, let’s be honest: a path string looks harmless. It’s just a path. The problem is that a path is an instruction telling the program where exactly to go for data. If that instruction comes from outside (arguments, configs, HTTP, an import, a test scenario), you effectively give the outside world a way to steer your program into unexpected places.

This is not some rare paranoia. The Go standard library as a whole is evolving toward a “safe by default” principle, and certain APIs explicitly close off classes of vulnerabilities related to file access and escaping restrictions.

Let’s imagine a typical “real life” task. In our training app (let’s call it tasker) we already know how to work with files: save data, read config, take templates from the data/ directory. Then a function appears:

“Show a template by the name name”.

And the user (or external code) passes name. Seems like—what could go wrong?

What path traversal means in plain English

To understand the problem, it helps to imagine a directory as a “yard with a fence”. os.DirFS("data") is our fence: we say “reading is allowed only inside data”. But an attacker (or just a curious colleague) can try to crawl under the fence using a special path shape.

Path traversal is an attempt to break out of the allowed root via segments like .. (go up one level) or similar tricks. For example, if your program thinks it is reading data/templates/welcome.txt, but it is fed "../secrets.txt", it may try to open something outside data.

The simplest “malicious” strings look like this:

  • "../secret.txt" — “go up one level, take secret.txt”
  • "a/../../secret.txt" — “first go into a, then go up twice”

And even if you are not writing an internet service but a regular CLI utility, the problem remains: the path may come from a script, CI, someone else’s config, an integration. In the end, a “local” program suddenly becomes part of a supply chain, and you can no longer trust it “like yourself on a Friday evening”.

2. Two path worlds: OS path and FS path

Now for a small but critical discipline: do not mix up OS paths and paths inside fs.FS.

An OS path is what lives in the real filesystem: there can be C:\..., there can be backslashes \ on Windows, there can be absolute paths, and there can be platform-specific rules.

An FS path is a filename inside the abstraction fs.FS. And here Go has a very practical idea: an FS path is a relative name, typically with slashes /, without any “OS absolute meaning”. This convention lets os.DirFS and fstest.MapFS behave the same way.

It’s convenient to keep the comparison in a table:

What we compare OS path Path inside fs.FS
Separator depends on OS (/ or \) always /
Can be absolute yes (/etc/hosts, C:\Windows\...) no
Normalization filepath.Clean more often path.Clean, but for security fs.ValidPath matters more
Context “files on disk” “files inside a specific FS”

Why does this matter for security? Because once you start “gluing” OS paths together by hand or letting absolute paths into an FS, you break boundaries. And boundaries are security.

4. fs.ValidPath: what it checks and what it does not do

Now the main character of the lecture: fs.ValidPath.

It’s important to treat fs.ValidPath(name) as a shape/format check, not a “does the file exist” check. It answers the question:

“Does this string look like a valid FS path?”

Not the question:

“Can I open the file and read data?”

Put simply, fs.ValidPath filters out “suspicious” variants where traversal typically lives:

  • a leading / (we do not need absolute FS paths)
  • .. segments (escaping “upwards”)
  • empty segments (for example, "a//b")
  • and, in general, violations of the path format inside an FS

A mini demo that’s useful to scan once:

package main

import (
	"fmt"
	"io/fs"
)

func main() {
	fmt.Println(fs.ValidPath("cfg/app.txt"))   // true
	fmt.Println(fs.ValidPath("../secret"))    // false
	fmt.Println(fs.ValidPath("/etc/passwd"))  // false
	fmt.Println(fs.ValidPath("a//b.txt"))     // false
}

Note a subtle point: fs.ValidPath("cfg/app.txt") == true does not mean the file exists. It only means: “the string looks acceptable”. Existence checks are done by Open, Stat, ReadFile.

5. Why Clean is not a defense

It’s very tempting to do it “nicely”: take the user input, “comb” it with Clean, and live peacefully. Beginners often do this because it looks logical: “I normalized the path, so it’s fine”.

The problem is that normalization can hide an attack rather than stop it.

Let’s look:

package main

import (
	"fmt"
	"io/fs"
	"path"
)

func main() {
	raw := "a/../b.txt"

	fmt.Println(path.Clean(raw))   // b.txt
	fmt.Println(fs.ValidPath(raw)) // false
}

Here path.Clean(raw) turns the path into "b.txt". In other words, the “escape up” traces disappear, and if you then blindly open "b.txt", you can no longer distinguish “normal input” from “input with a traversal attempt”.

So the rule of the day is: for external input, “reject” is better than “clean and accept”. fs.ValidPath is exactly about that: if the path is invalid, return an error and do not perform any filesystem operations.

6. Safe template reading

Let’s do something practical: a function for reading a “template” from an FS. We assume there is a template directory somewhere in the app (for example, templates/ inside our FS), and we want to read a file by a name that came from outside.

Let’s create the package internal/templates (the name is not important), and inside it the function ReadTemplate.

package templates

import (
	"fmt"
	"io/fs"
)

func ReadTemplate(fsys fs.FS, name string) (string, error) {
	if !fs.ValidPath(name) {
		return "", fmt.Errorf("invalid template path: %q", name)
	}

	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("read template %q: %w", name, err)
	}
	return string(b), nil
}

There are two important habits here right away.

First, we validate name before reading the file: if it’s an external string, it must pass the “security checkpoint”.

Second, we add context to the error via fmt.Errorf(... %w ...), so that at the top level you can show a message to a human and still preserve the cause. This style is considered a basic engineering practice in Go because it does not break cause matching via errors.Is/errors.As.

7. os.DirFS: a “fence” + name validation

In practice, defense is usually done in two layers.

The first layer is to restrict the root via os.DirFS("data") (or another directory). This is like a “fence”: even if someone tries to be clever, the FS itself is considered root-restricted.

The second layer is to validate the name via fs.ValidPath. This is like a “pass check”: we say “you cannot enter at all with these suspicious documents”.

A mini usage example (say, in main or in the application layer):

package main

import (
	"fmt"
	"os"

	"example.com/tasker/internal/templates"
)

func main() {
	fsys := os.DirFS("data")

	text, err := templates.ReadTemplate(fsys, "templates/welcome.txt")
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	fmt.Println(text)
}

Note: ReadTemplate knows nothing about disk, current directory, or absolute paths. It works with fs.FS, which means you can test it without touching the disk.

8. Scenarios, tests, and an access policy

What fs.ValidPath gives you in real life

It’s useful to talk through the effect “point by point”, but let’s do it in human language without turning the lecture into a checklist.

If the user accidentally enters "/templates/welcome.txt", that’s not a “scary attack”, but it’s the wrong FS-path shape. fs.ValidPath will reject it immediately and give you a chance to return a clear error: “expected a relative path inside the FS”.

If the user enters "../welcome.txt", that is classic traversal. We do not try to “fix” the path and guess what they meant. We say “no”. And this “no” is very useful because it turns a potential security issue into a routine validation error.

If the user enters "templates//welcome.txt", that’s a strange path form: two slashes in a row. fs.ValidPath will reject that as well. As a result, you get a single path format standard: fewer surprises, less “it worked on my machine”.

Mini tests via fstest.MapFS

Since in the previous lecture we learned how to test file logic without disk, let’s reinforce it: path security is exactly the kind of thing that is convenient to test.

Let’s write a test: “a valid path is read”, “an invalid path is rejected”.

package templates

import (
	"testing"
	"testing/fstest"
)

func TestReadTemplate_OK(t *testing.T) {
	fsys := fstest.MapFS{
		"templates/welcome.txt": {Data: []byte("hi")},
	}

	got, err := ReadTemplate(fsys, "templates/welcome.txt")
	if err != nil {
		t.Fatalf("ReadTemplate: %v", err)
	}
	if got != "hi" {
		t.Fatalf("got %q", got)
	}
}

And now the key part: a traversal test. We do not have to check the error text down to the character (that often makes tests brittle), but we definitely want to ensure that the function does not read the file and returns an error.

package templates

import (
	"testing"
	"testing/fstest"
)

func TestReadTemplate_TraversalBlocked(t *testing.T) {
	fsys := fstest.MapFS{
		"secret.txt": {Data: []byte("nope")},
	}

	_, err := ReadTemplate(fsys, "../secret.txt")
	if err == nil {
		t.Fatalf("expected error")
	}
}

This test does not prove we are “protected from everything in the universe”. But it guarantees a basic thing: our function does not accept paths with ... And that is already a huge step away from “naively reading a file by a string”.

Access policy: ValidPath does not replace application rules

It’s important not to fall into magical thinking: “since ValidPath exists, security is done”. It’s only a format check.

For example, a valid FS path "templates/welcome.txt" can still be “the wrong file” if you placed extra files in your FS. So you still have an architectural responsibility: choose the DirFS root correctly, organize files correctly, and, when possible, create separate directories for different data types.

In real applications it’s often useful to have a clear prefix and verify it (for example, allow only templates/...). This is not fs.ValidPath’s job; it’s your business rule: “which files exactly can be read”.

If you want to wire it in neatly, you can do this:

package templates

import (
	"fmt"
	"io/fs"
	"strings"
)

func ReadTemplate(fsys fs.FS, name string) (string, error) {
	if !fs.ValidPath(name) {
		return "", fmt.Errorf("invalid template path: %q", name)
	}
	if !strings.HasPrefix(name, "templates/") {
		return "", fmt.Errorf("template must be under templates/: %q", name)
	}

	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("read template %q: %w", name, err)
	}
	return string(b), nil
}

Yes, that’s one more if. But it’s an if that saves hours of investigation and makes the program’s behavior predictable.

9. Flowchart for safe file reading

Sometimes it’s useful to see the process as an algorithm, especially if you are just getting used to the Go approach of “validation → early return → action”.

flowchart TD
    A["name came from outside"] --> B{"fs.ValidPath(name)?"}
    B -- no --> C["return error: invalid path"]
    B -- yes --> D{"strings.HasPrefix(name, 'templates/')?"}
    D -- no --> E["return error: forbidden area"]
    D -- yes --> F["fs.ReadFile(fsys, name)"]
    F --> G{"err?"}
    G -- yes --> H["return error (wrap)"]
    G -- no --> I["return content"]

This diagram is useful because it shows: security is not “one magic function”, but a sequence of simple input checks.

10. Common mistakes when using fs.ValidPath and traversal protection

Mistake #1: validating the path after reading the file.
It sounds funny, but it happens regularly: people call fs.ReadFile first, and then, if something goes wrong, start checking the string. Validation must come before I/O. Otherwise you have already attempted a potentially dangerous operation.

Mistake #2: thinking fs.ValidPath checks whether the file exists.
ValidPath is not about existence at all. It does not touch the FS and does not call Open. It only answers: “the string is acceptable by format”. Existence is checked by Open/Stat/ReadFile, and those have their own errors.

Mistake #3: “cleaning” the path via path.Clean and assuming the problem is solved.
Normalization is useful for internal paths you generate yourself. But for external input, “clean and accept” is a bad strategy, because you may turn suspicious input into “ordinary” input and lose the ability to reject it.

Mistake #4: building an FS path via filepath.Join.
filepath.Join builds paths according to OS rules. On Windows it may return a string with \, while the FS world lives on /. As a result, fs.ValidPath may reject the path, and you will stare at the string for a long time thinking “but join is smart”. For FS paths, path.Join is usually more appropriate.

Mistake #5: forgetting that os.DirFS sets the root rather than “gluing a prefix”.
Some people try to do this: dir := "data"; name := dir + "/" + userInput. That takes you back to manual string concatenation, where it’s easy to make mistakes and where traversal defense becomes entirely your problem. It’s much calmer to create fsys := os.DirFS("data"), and then work with relative FS paths and validate them.

1
Task
Go SELF, level 43, lesson 3
Locked
Path validation
Path validation
1
Task
Go SELF, level 43, lesson 3
Locked
Filter report
Filter report
1
Task
Go SELF, level 43, lesson 3
Locked
Config access
Config access
1
Task
Go SELF, level 43, lesson 3
Locked
Email templates
Email templates
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION