Harden a Go Code Reviewer with os.Root
Use os.Root to confine a Go code reviewer and block symlink escapes.
Introduction
30 Second Summary
A code review helper needs permission to open files on your behalf. A misleading path can expose files outside the folder you meant to share.
In this project, you will harden the Go tutor's code reviewer with os.Root, a standard library boundary that confines file access to one trusted folder. You'll reproduce a leak through a symbolic link before table-driven regression tests prove that escape paths stay blocked.
What You'll Build
You'll watch a vulnerable test leak an outside Go file before the secure reader blocks that same attack in a passing regression suite.
By the end of this project, you'll have:
- A visible leak demonstration that reads package secret through sneaky.go, proving the test can reproduce the original defect.
- A confined file reader built with ReadGoFile that opens agent.go while refusing paths that escape the trusted folder.
- A focused security patch on a local Git branch, backed by named tests that show why each unsafe request was refused.
- Secret Mission: Prove that a two-link symlink chain cannot bypass the same filesystem boundary.
Do I need any experience before starting?
Basic familiarity with variables and functions helps, but no prior Go security experience is required. You'll need a Mac with Git and Go 1.27.0 installed.
Before We Start
Before any hands-on work, lock in the security boundary you are about to protect. You are hardening the Go tutor code reviewer's file-reading capability because model-selected paths are untrusted and can escape the intended filesystem boundary.
Set Up the Security Lab
A security result is useful only when another developer can reproduce it from the same source snapshot. A green baseline keeps existing repository problems separate from the patch you add later.
Git pins the repository history to one known commit. Go 1.27.0 matches the module's required toolchain.
Visual Studio Code keeps the source files beside every test result. Its integrated terminal runs each check from the cloned repository.
In This Step, Get Ready To:
- Verify the required command-line tools.
- Prepare the editor with a local copy of the repository.
- Establish a green baseline on the pinned branch.
Verify Git and Go
Version checks turn assumptions about your Mac into visible evidence. They confirm that the tools can run before the repository depends on them.
- Press Cmd+Space to open Spotlight.
- Type Terminal in the search field.
- Press Enter to open macOS Terminal.
- Check the Git installation by running this command:
git --version
What Does This Check Show?
The command asks Git to print its installed version. A version number confirms that Terminal can find the Git executable.
✔️ I See a Git Version
Git is ready to track the pinned source snapshot.
ⓧ No Git Version Appears
Your Mac needs a working Git installation before it can clone the repository.
- Open the official Git website.
- Choose the macOS installation route shown on the website.
- Complete the installation using its guided prompts.
- Quit Terminal after the installation finishes.
- Repeat the Spotlight steps above to reopen Terminal.
- Confirm that Git is available by running this command:
git --version
What Confirms Git Is Ready?
A printed version number confirms that the new Terminal session can find Git.
The repository declares Go 1.27.0 as its minimum version. The next check confirms that your installed toolchain meets that requirement.
- Check the Go toolchain by running this command:
go version
What Does the Go Check Show?
The output identifies the installed Go release. Look for go1.27.0 in the version information.
✔️ I See the Required Go Version
Good, your Mac has the exact Go toolchain required by this repository.
ⓧ I See an Older Go Version
An older toolchain does not satisfy the repository's minimum Go version.
- Open the official Go downloads page.
- Download the Go 1.27.0 macOS package that matches your Mac.
- Run the downloaded package installer.
- Complete the installation using its guided prompts.
- Quit Terminal after the installation finishes.
- Repeat the Spotlight steps above to reopen Terminal.
- Confirm the upgraded toolchain by running this command:
go version
What Confirms the Upgrade?
The output should now include go1.27.0.
ⓧ No Go Version Appears
Your Mac needs the repository's required Go toolchain before it can run the tests.
- Open the official Go downloads page.
- Download the Go 1.27.0 macOS package that matches your Mac.
- Run the downloaded package installer.
- Complete the installation using its guided prompts.
- Quit Terminal after the installation finishes.
- Repeat the Spotlight steps above to reopen Terminal.
- Confirm the installed toolchain by running this command:
go version
What Confirms the Installation?
The output should include go1.27.0.
Still Missing a Tool Version?
Make sure you reopened Terminal after installing the tool. A Terminal session that stayed open may not detect a newly installed executable.
Ask for help with the output you see: help me diagnose why my Mac cannot find Git or Go after installation.
Prepare the Editor and Repository
The repository needs a predictable local home before you add the security package. Keeping the clone on your Desktop also makes it easy to locate from the editor.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code in the search field.
- Press Enter to try opening the editor.
✔️ Visual Studio Code Opens
Your editor is ready for the repository.
ⓧ Visual Studio Code Is Missing
The macOS installer packages the editor as an application you can keep in your Applications folder.
- Open the official macOS setup page.
- Download the macOS .dmg.
- Open the downloaded .dmg.
- Drag Visual Studio Code.app into Applications.
- Repeat the Spotlight steps above to open Visual Studio Code.
The editor is ready. You can now place the repository somewhere visible on your Mac.
- Switch back to the macOS Terminal window from earlier.
- Move to your Desktop by running this command:
cd ~/Desktop
What Does This Command Do?
The command changes Terminal's current location to your Desktop folder. The clone created next appears there.
- Clone the public source repository by running this command:
git clone https://github.com/ZAG23/nextwork-go-tutor
What Does the Clone Command Do?
Git downloads the repository history into a new nextwork-go-tutor folder. The source is public, so this clone does not require a GitHub account.
You'll see Git report its download progress before returning control to the terminal prompt.
Did the Clone Stop?
Check that your Mac has an internet connection. Remove any partially created nextwork-go-tutor folder before retrying the clone.
Ask for help with the visible output: help me diagnose why this public Git repository did not clone.
- Confirm that the repository folder exists by running this command:
ls
What Does This Check Show?
The command lists the items on your Desktop. The cloned repository should appear as nextwork-go-tutor.
Your local copy now exists independently of the public source.
Visual Studio Code may show a Workspace Trust prompt when the repository opens. The public source is the repository used throughout this project.
- Switch to Visual Studio Code.
- Click File in the top menu bar.
- Click Open Folder.
- Select the Desktop folder.
- Select the nextwork-go-tutor folder.
- Click Open.
You'll see nextwork-go-tutor at the top of the Explorer sidebar.
- Click Yes, I trust the authors if the Workspace Trust prompt appears.
- Right-click nextwork-go-tutor in the Explorer sidebar.
- Select Open in Integrated Terminal.
You'll see a terminal panel inside Visual Studio Code. Its current folder is the cloned nextwork-go-tutor repository.
Pin the Snapshot and Test the Baseline
A dedicated branch isolates your security work from the repository's main history. Starting it at the researched commit makes every later test reproducible.
- Create the security branch at the pinned snapshot by running this command in the integrated terminal:
git switch -c harden-file-reader 847fa52e0e2e105268d7e62019266729e8525a8b
What Does This Command Do?
Git creates harden-file-reader at commit 847fa52e0e2e105268d7e62019266729e8525a8b. It also switches your working copy to that branch.
The terminal confirms that you switched to the new branch. Your future security changes stay focused there.
Could Git Not Create the Branch?
Confirm that the integrated terminal belongs to the nextwork-go-tutor folder. A missing commit usually means the clone did not finish successfully.
Ask for help with the visible Git output: help me diagnose why the pinned branch could not be created.
Before you run the baseline check, do you expect the repository's existing Go tutor tests to pass or expose a pre-existing failure?
- Establish the green baseline by running this command:
go test ./go-tutor/
What Does the Baseline Prove?
Go compiles the existing tutor package before running its tests. A passing result proves that the pinned repository is healthy before you add the security lab.
You'll see the command exit successfully with a result beginning with ok. That is the clean baseline your security patch can build on.
Did the Baseline Test Fail?
Check that the integrated terminal is inside nextwork-go-tutor. Confirm that the Go version output includes go1.27.0.
Ask for help with the failed test output: help me diagnose the Go tutor baseline failure.
Your tools, editor, repository, branch, and baseline are ready. Next, you'll reproduce the symlink leak inside an isolated test.
Reproduce the Symlink Leak
Your green baseline proves the original Go tutor still works at the pinned snapshot. Its code reviewer still passes a model-selected path directly to the filesystem.
A model-selected path is untrusted input because the model can choose a path the developer did not intend. You will reproduce a symbolic-link escape in test code so the outside-file leak becomes visible.
In this step, get ready to:
- Trace defect D6 to its direct filesystem read.
- Build the test-only reader with its temporary-file helper.
- Run a test that exposes the symlink leak.
Inspect defect D6
Defect D6 reads the path in in.FilePath without checking where that path leads. Tracing the call shows where model-controlled input reaches the filesystem.
- Return to the Explorer sidebar in Visual Studio Code.
- Expand the docs/original-code folder.
- Select file_tools.go.txt.
- Scroll to the code documented as defect D6.
- Locate the direct os.ReadFile(in.FilePath) call.
The value in in.FilePath reaches os.ReadFile unchanged. A model can therefore select any path that the process can reach.
Why Can a Symlink Escape?
A symbolic link is a filesystem entry that points to another path. A link inside an allowed folder can target a file outside that folder.
The direct read follows the link to its destination. The allowed-looking path can therefore reveal outside data.
Create the test-only reader
The security lab keeps the vulnerable reader inside reader_test.go. This placement contains the demonstration within test code.
- Select the nextwork-go-tutor folder in the Explorer sidebar.
- Use the Explorer folder-creation control to create security-lab inside nextwork-go-tutor.
You should see security-lab beneath the repository folder in Explorer.
- Use the Explorer file-creation control to create reader.go inside security-lab.
- Use the same control to create reader_test.go inside security-lab.
You should see both files listed inside security-lab.
Every Go source file starts with a package declaration. Both files use securitylab so the tests can call package functions directly.
- Select reader.go in Explorer.
- Set the file's contents by pasting this code:
package securitylab
What Does This Code Do?
The package declaration assigns reader.go to the securitylab package. The production reader joins this package in the next step.
- Save reader.go.
- Confirm the new package compiles by running this command in the integrated terminal:
go test -v ./security-lab
What Does This Check Prove?
A successful command confirms that Go recognizes security-lab as a package. It also confirms that the package declaration is valid.
Package Not Compiling?
Check that reader.go contains exactly package securitylab. Confirm that the file sits inside the repository's security-lab folder.
Ask for help if the package still fails to compile: help me diagnose why my security-lab Go package does not compile.
✔️ Awesome, I've got everything!
Your reader.go file now contains the package declaration required for this step.
ⓧ I'd like to double check the full code
package securitylab
What Should This File Contain?
This file contains only the package declaration at this stage. Compare it with your saved reader.go file.
The vulnerable helper combines a trusted root with the requested path. The fixture helper creates controlled files for the demonstration.
- Switch to reader_test.go in Explorer.
- Add the package imports and helper functions by pasting this code:
package securitylab
import (
"os"
"path/filepath"
"testing"
)
// naiveReadFile is intentionally vulnerable and exists only to demonstrate
// why joining a trusted root to an untrusted path is not a security boundary.
func naiveReadFile(root, requestedPath string) ([]byte, error) {
return os.ReadFile(filepath.Join(root, requestedPath))
}
func mustWriteFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
What Do These Helpers Do?
- The imports provide filesystem paths and test utilities for the demonstration.
- The naiveReadFile helper joins the trusted root to an untrusted requested path. It passes that result directly to os.ReadFile.
- The mustWriteFile helper creates each fixture file. It stops the test immediately when fixture creation fails.
- Save reader_test.go.
- Confirm the helper code compiles by running this command:
go test -v ./security-lab
What Does This Check Prove?
A successful command confirms that the imports and helper signatures compile together. Your vulnerable reader is now ready for a controlled exploit test.
Seeing a Compile Failure?
Check that the import block contains os, path/filepath, and testing. Confirm that both helper functions close with matching braces.
Ask for help with the exact compiler output: help me fix the helper code in reader_test.go.
Demonstrate the symlink leak
The demonstration needs two temporary directories. One represents the allowed root while the other holds the outside secret.
The test places sneaky.go inside the root as a link to secret.go outside it. The vulnerable helper follows that link.
- Add the exploit test below mustWriteFile in reader_test.go by pasting this code:
func TestNaiveReaderLeaksThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
link := filepath.Join(root, "sneaky.go")
if err := os.Symlink(outsideGo, link); err != nil {
t.Fatalf("create symlink: %v", err)
}
got, err := naiveReadFile(root, "sneaky.go")
if err != nil {
t.Fatalf("naive read: %v", err)
}
if string(got) != "package secret\n" {
t.Fatalf("got %q, want leaked secret", got)
}
t.Logf("LEAKED: %s", got)
}
How Does the Exploit Work?
- The two t.TempDir calls create separate locations for the allowed root and outside data.
- The mustWriteFile call writes package secret to the outside file.
- The os.Symlink call creates sneaky.go inside the allowed root. Its destination remains outside that root.
- The final checks prove that naiveReadFile returned the outside contents through the inside link.
- Save reader_test.go.
Before you run the test, do you think the reader stays inside the temporary root?
- Run only the symlink demonstration with this command:
go test -v ./security-lab -run TestNaiveReaderLeaksThroughSymlink
What Does This Command Prove?
The -run flag selects TestNaiveReaderLeaksThroughSymlink. Verbose output includes the test's leak log.
You should see LEAKED: package secret in the terminal. The test passes because it verifies that the vulnerable reader returned the outside contents.
You have reproduced defect D6 as a visible filesystem escape. The test now preserves evidence of the exact behavior the secure reader must block.
Leak Test Not Passing?
Check that sneaky.go points to outsideGo. Confirm that naiveReadFile reads the result of filepath.Join.
A symlink creation failure can come from an incorrect path in the test fixture. Ask for help with the terminal output: help me debug why the symlink leak test is failing.
✔️ Awesome, I've got everything!
Your reader_test.go file now contains the complete leak demonstration for this step.
ⓧ I'd like to double check the full code
package securitylab
import (
"os"
"path/filepath"
"testing"
)
// naiveReadFile is intentionally vulnerable and exists only to demonstrate
// why joining a trusted root to an untrusted path is not a security boundary.
func naiveReadFile(root, requestedPath string) ([]byte, error) {
return os.ReadFile(filepath.Join(root, requestedPath))
}
func mustWriteFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestNaiveReaderLeaksThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
link := filepath.Join(root, "sneaky.go")
if err := os.Symlink(outsideGo, link); err != nil {
t.Fatalf("create symlink: %v", err)
}
got, err := naiveReadFile(root, "sneaky.go")
if err != nil {
t.Fatalf("naive read: %v", err)
}
if string(got) != "package secret\n" {
t.Fatalf("got %q, want leaked secret", got)
}
t.Logf("LEAKED: %s", got)
}
How Should You Use This Reference?
This reference shows the exact cumulative test file for this step. Compare its imports and function order with your saved reader_test.go file.
Your security lab now proves that a trusted-looking path can expose an outside file. Next, you will replace that behavior with a filesystem boundary that refuses the same attack.
Confine Reads with os.Root
Your test-only reader has already shown a model-controlled symbolic link exposing a file outside the allowed folder. That leak proves the reviewer needs a filesystem boundary around every requested path.
String-based path joining leaves symbolic-link resolution outside the reviewer's control. In this step, os.Root confines each inspection and read to the trusted directory tree.
In this step, get ready to:
- Build ReadGoFile around the os.Root boundary.
- Create a table-driven test for one allowed path plus three escape attempts.
- Compare the secure refusals with the existing symlink leak.
Build the confined reader
An os.Root value represents one trusted directory tree. Its Stat and ReadFile methods reject paths or followed links that escape that tree.
- In security-lab/reader.go, add this confined reader below the existing package securitylab declaration:
import (
"fmt"
"os"
)
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
func ReadGoFile(rootPath, requestedPath string) ([]byte, error) {
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open review root: %w", err)
}
defer root.Close()
if _, err := root.Stat(requestedPath); err != nil {
return nil, fmt.Errorf("inspect %q: %w", requestedPath, err)
}
data, err := root.ReadFile(requestedPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", requestedPath, err)
}
return data, nil
}
Why Use os.Root?
- os.OpenRoot establishes the trusted directory boundary.
- root.Stat inspects the requested target through that boundary.
- root.ReadFile reads the file through the same boundary.
- fmt.Errorf adds context while %w preserves the original error.
- Save security-lab/reader.go.
Before you run the suite, do you expect the existing leak demonstration to keep passing after the secure reader is added?
- Confirm the package still compiles by running this command:
go test -v ./security-lab
What Should You See?
The package should pass. The earlier test still logs LEAKED: package secret because its deliberately unsafe helper remains isolated in test code.
Is the Package Failing to Compile?
Check that the import block sits directly below package securitylab. Confirm every opening brace has a matching closing brace.
Confirm the function calls os.OpenRoot instead of opening the requested path directly.
Help me diagnose why ReadGoFile does not compile.
✔️ Awesome, I've got everything!
Great. Double-check that you saved security-lab/reader.go before continuing.
ⓧ I'd like to double check the full code
package securitylab
import (
"fmt"
"os"
)
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
func ReadGoFile(rootPath, requestedPath string) ([]byte, error) {
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open review root: %w", err)
}
defer root.Close()
if _, err := root.Stat(requestedPath); err != nil {
return nil, fmt.Errorf("inspect %q: %w", requestedPath, err)
}
data, err := root.ReadFile(requestedPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", requestedPath, err)
}
return data, nil
}
How to Compare This File
The complete file contains the package declaration followed by the imports. The ReadGoFile function opens one root for both inspection and reading.
Add the table-driven regression test
A table-driven test stores each path scenario as data. One shared test loop then applies the same reader checks to every named case.
- In security-lab/reader_test.go, add this initial allowed-file test below TestNaiveReaderLeaksThroughSymlink.
func TestReadGoFile(t *testing.T) {
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ReadGoFile(root, tc.path)
if err != nil {
t.Fatalf("ReadGoFile(%q): %v", tc.path, err)
}
if string(got) != tc.want {
t.Fatalf("ReadGoFile(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
What Does This Test Do?
- t.TempDir creates an isolated trusted directory for this test.
- mustWriteFile places an allowed agent.go file inside that directory.
- tests holds the named input plus the expected result.
- t.Run gives each table entry its own visible subtest result.
- Save security-lab/reader_test.go.
Before you run the suite, do you expect the confined reader to return the contents of agent.go?
- Verify the allowed-file case by running this command:
go test -v ./security-lab
What Should You See?
You should see TestReadGoFile/allows_Go_file pass. The earlier vulnerable test also passes after logging its deliberate leak.
Does the Allowed File Test Fail?
Confirm agent.go is created inside the temporary root directory. Check that its expected contents include the final newline.
Help me fix the allowed-file subtest.
The allowed case proves ordinary review access still works. The next table entries send parent traversal, an absolute path, and a symlink escape through the same function.
- In TestReadGoFile, find this setup and table:
root := t.TempDir()
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
}
What Are You Finding?
This is the current test setup plus its one allowed table entry. The replacement keeps that case while adding the attack fixtures.
- Replace the matched setup and table with this expanded version:
root := t.TempDir()
outside := t.TempDir()
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
if err := os.Symlink(outsideGo, filepath.Join(root, "sneaky.go")); err != nil {
t.Fatalf("create symlink: %v", err)
}
traversal, err := filepath.Rel(root, outsideGo)
if err != nil {
t.Fatalf("build traversal path: %v", err)
}
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
{name: "blocks parent traversal", path: traversal, wantErr: true},
{name: "blocks absolute escape", path: outsideGo, wantErr: true},
{name: "blocks symlink escape", path: "sneaky.go", wantErr: true},
}
How Do the Attack Cases Differ?
- traversal reaches the outside file through parent-directory components.
- outsideGo supplies the outside file as an absolute path.
- sneaky.go starts inside the root but follows a symbolic link outside it.
- wantErr marks each attack as a case the secure reader must refuse.
- In the t.Run callback, find this current result handling:
got, err := ReadGoFile(root, tc.path)
if err != nil {
t.Fatalf("ReadGoFile(%q): %v", tc.path, err)
}
if string(got) != tc.want {
t.Fatalf("ReadGoFile(%q) = %q, want %q", tc.path, got, tc.want)
}
What Is Missing Here?
The current branch only handles successful reads. The denied cases need a separate path that treats an error as the expected secure result.
- Replace the current result handling with this refusal-aware version:
got, err := ReadGoFile(root, tc.path)
if tc.wantErr {
if err == nil {
t.Fatalf("ReadGoFile(%q) succeeded, want refusal", tc.path)
}
t.Logf("blocked %q: %v", tc.path, err)
return
}
if err != nil {
t.Fatalf("ReadGoFile(%q): %v", tc.path, err)
}
if string(got) != tc.want {
t.Fatalf("ReadGoFile(%q) = %q, want %q", tc.path, got, tc.want)
}
How Does the Refusal Branch Work?
A denied case fails if ReadGoFile returns no error. A real refusal is logged before the subtest returns successfully.
- Save security-lab/reader_test.go.
✔️ Awesome, I've got everything!
Your regression test now covers one allowed file plus three escape paths. Continue to the final comparison.
ⓧ I'd like to double check the full code
package securitylab
import (
"os"
"path/filepath"
"testing"
)
// naiveReadFile is intentionally vulnerable and exists only to demonstrate
// why joining a trusted root to an untrusted path is not a security boundary.
func naiveReadFile(root, requestedPath string) ([]byte, error) {
return os.ReadFile(filepath.Join(root, requestedPath))
}
func mustWriteFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestNaiveReaderLeaksThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
link := filepath.Join(root, "sneaky.go")
if err := os.Symlink(outsideGo, link); err != nil {
t.Fatalf("create symlink: %v", err)
}
got, err := naiveReadFile(root, "sneaky.go")
if err != nil {
t.Fatalf("naive read: %v", err)
}
if string(got) != "package secret\n" {
t.Fatalf("got %q, want leaked secret", got)
}
t.Logf("LEAKED: %s", got)
}
func TestReadGoFile(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
if err := os.Symlink(outsideGo, filepath.Join(root, "sneaky.go")); err != nil {
t.Fatalf("create symlink: %v", err)
}
traversal, err := filepath.Rel(root, outsideGo)
if err != nil {
t.Fatalf("build traversal path: %v", err)
}
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
{name: "blocks parent traversal", path: traversal, wantErr: true},
{name: "blocks absolute escape", path: outsideGo, wantErr: true},
{name: "blocks symlink escape", path: "sneaky.go", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ReadGoFile(root, tc.path)
if tc.wantErr {
if err == nil {
t.Fatalf("ReadGoFile(%q) succeeded, want refusal", tc.path)
}
t.Logf("blocked %q: %v", tc.path, err)
return
}
if err != nil {
t.Fatalf("ReadGoFile(%q): %v", tc.path, err)
}
if string(got) != tc.want {
t.Fatalf("ReadGoFile(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
How to Compare This File
The vulnerable demonstration remains above TestReadGoFile. The secure test builds fresh fixtures before running every named table entry through ReadGoFile.
Compare the vulnerable and confined readers
Both readers now face the same symlink attack. Their different results reveal whether the directory boundary is actually enforcing confinement.
Before you run the full suite, which reader do you expect to expose the outside file?
- Run the complete security lab by entering this command:
go test -v ./security-lab
What Should You See?
- TestNaiveReaderLeaksThroughSymlink logs LEAKED: package secret and passes.
- TestReadGoFile/allows_Go_file passes with the contents of agent.go.
- TestReadGoFile/blocks_parent_traversal logs a refusal and passes.
- TestReadGoFile/blocks_absolute_escape plus TestReadGoFile/blocks_symlink_escape also log refusals and pass.
- ok appears in the final package summary.
Do Any Secure Subtests Fail?
A missing wantErr branch makes expected refusals fail. Compare that branch with the full-file reference.
Confirm ReadGoFile calls root.Stat plus root.ReadFile on the requested path.
Help me diagnose a failing os.Root regression subtest.
That is the boundary working: agent.go stays readable while the leaking sneaky.go path is refused.
Your reviewer now has a tested filesystem boundary. Next, you will restrict which in-root targets count as reviewable Go source.
Restrict Reviewable Files
Your os.Root boundary now keeps model-selected paths inside the trusted directory. That closes the escape route you reproduced in the previous step.
Location is only one part of safe file access. A file-type allowlist also keeps unrelated files and directories away from the code reviewer.
In this step, get ready to:
- Reject empty review requests.
- Allow only regular Go source files.
- Verify each refusal with a named subtest.
Reject empty and non-Go paths
An empty path gives the reviewer no valid target. Checking filepath.Ext also creates an exact allowlist for files ending in .go.
- In the Visual Studio Code Explorer sidebar, select security-lab/reader.go.
- Update the import block by replacing it with the code below:
import (
"fmt"
"os"
"path/filepath"
)
What does this import add?
- The path/filepath package supplies Ext for reading the requested path's extension.
- The existing fmt package builds useful refusal messages.
- The existing os package keeps the confined root operations in place.
- Replace the existing ReadGoFile function with this secured version:
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
func ReadGoFile(rootPath, requestedPath string) ([]byte, error) {
if requestedPath == "" {
return nil, fmt.Errorf("file path is empty")
}
if filepath.Ext(requestedPath) != ".go" {
return nil, fmt.Errorf("only .go files are reviewable")
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open review root: %w", err)
}
defer root.Close()
info, err := root.Stat(requestedPath)
if err != nil {
return nil, fmt.Errorf("inspect %q: %w", requestedPath, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%q is a directory", requestedPath)
}
data, err := root.ReadFile(requestedPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", requestedPath, err)
}
return data, nil
}
What does this policy do?
- The first check rejects an empty requestedPath before the root is opened.
- The extension check allows only the exact .go suffix.
- The info variable keeps the metadata returned by root.Stat.
- The info.IsDir() check rejects directories whose names happen to end in .go.
- The existing os.Root operations continue to enforce the directory boundary.
- Save security-lab/reader.go.
- Confirm the earlier confinement tests still pass by running this command:
go test -v ./security-lab
What should you see?
The existing leak demonstration and secure-reader tests pass. Strong start. Your new policy checks have preserved the filesystem boundary.
Seeing a compile error?
Confirm that "path/filepath" appears inside the import block. Check that info replaces the earlier blank identifier before root.Stat.
Compare every brace in ReadGoFile with the reference below.
Help me fix the compile error in my updated ReadGoFile function.
✔️ Awesome, I've got everything!
Great. Double-check that you saved security-lab/reader.go before moving to the test fixtures.
ⓧ I'd like to double check the full code
package securitylab
import (
"fmt"
"os"
"path/filepath"
)
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
func ReadGoFile(rootPath, requestedPath string) ([]byte, error) {
if requestedPath == "" {
return nil, fmt.Errorf("file path is empty")
}
if filepath.Ext(requestedPath) != ".go" {
return nil, fmt.Errorf("only .go files are reviewable")
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open review root: %w", err)
}
defer root.Close()
info, err := root.Stat(requestedPath)
if err != nil {
return nil, fmt.Errorf("inspect %q: %w", requestedPath, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%q is a directory", requestedPath)
}
data, err := root.ReadFile(requestedPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", requestedPath, err)
}
return data, nil
}
Add blocked file fixtures
The tests need real examples of content the reviewer should refuse. A directory named folder.go proves that an extension check cannot identify a regular source file by itself.
- Switch back to security-lab/reader_test.go in the Explorer sidebar.
- Find the agent.go fixture inside TestReadGoFile.
- Add the following fixtures immediately below it:
mustWriteFile(t, filepath.Join(root, "notes.txt"), []byte("not Go source\n"))
if err := os.Mkdir(filepath.Join(root, "folder.go"), 0o700); err != nil {
t.Fatalf("create directory: %v", err)
}
What do these fixtures represent?
- The notes.txt fixture represents a regular file outside the Go source allowlist.
- The folder.go fixture has an allowed-looking suffix. Its directory metadata makes it ineligible for review.
- Both fixtures live inside the trusted root. Their rejection comes from the new content policy.
- Save security-lab/reader_test.go.
- Check that the expanded test setup remains valid by running:
go test -v ./security-lab
What does this check prove?
The suite still passes after creating both fixtures. The test root now contains the inputs needed to challenge the new policy.
Fixture setup failing?
Confirm that both additions sit inside TestReadGoFile after the agent.go fixture. Check that folder.go uses os.Mkdir.
Help me fix the new notes.txt or folder.go test fixture.
Run the policy regression suite
Each policy belongs in its own named case within the existing tests table. The existing t.Run loop turns those cases into separate results in verbose output.
- Locate the existing tests table inside TestReadGoFile.
- Replace the table with the expanded version below:
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
{name: "blocks parent traversal", path: traversal, wantErr: true},
{name: "blocks absolute escape", path: outsideGo, wantErr: true},
{name: "blocks symlink escape", path: "sneaky.go", wantErr: true},
{name: "blocks non-Go file", path: "notes.txt", wantErr: true},
{name: "blocks directories", path: "folder.go", wantErr: true},
{name: "blocks empty path", path: "", wantErr: true},
}
What does the table cover?
- The notes.txt case proves that a regular non-Go file is refused.
- The folder.go case proves that an allowed extension cannot make a directory reviewable.
- The empty string case proves that missing input is rejected before filesystem access begins.
- The earlier traversal cases remain in the table. This keeps confinement and file eligibility under one regression suite.
- Save security-lab/reader_test.go.
Before you run the suite, predict the three new policy names you expect to see.
- Run the complete verbose security suite with this command:
go test -v ./security-lab
What should you see?
You'll see blocks_non-Go_file, blocks_directories, and blocks_empty_path pass alongside the earlier escape cases.
That's the policy boundary working. The package finishes with an ok result while agent.go remains readable.
One of the new subtests failing?
If the non-Go case succeeds unexpectedly, compare the filepath.Ext condition with ".go".
If the directory case succeeds unexpectedly, confirm that root.Stat stores its result in info. Confirm that info.IsDir() runs before root.ReadFile.
Help me debug the failing file-policy subtest.
✔️ Awesome, I've got everything!
Your production policy and named regression cases are complete. Double-check that security-lab/reader_test.go is saved.
ⓧ I'd like to double check the full code
package securitylab
import (
"os"
"path/filepath"
"testing"
)
// naiveReadFile is intentionally vulnerable and exists only to demonstrate
// why joining a trusted root to an untrusted path is not a security boundary.
func naiveReadFile(root, requestedPath string) ([]byte, error) {
return os.ReadFile(filepath.Join(root, requestedPath))
}
func mustWriteFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestNaiveReaderLeaksThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
link := filepath.Join(root, "sneaky.go")
if err := os.Symlink(outsideGo, link); err != nil {
t.Fatalf("create symlink: %v", err)
}
got, err := naiveReadFile(root, "sneaky.go")
if err != nil {
t.Fatalf("naive read: %v", err)
}
if string(got) != "package secret\n" {
t.Fatalf("got %q, want leaked secret", got)
}
t.Logf("LEAKED: %s", got)
}
func TestReadGoFile(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
mustWriteFile(t, filepath.Join(root, "notes.txt"), []byte("not Go source\n"))
if err := os.Mkdir(filepath.Join(root, "folder.go"), 0o700); err != nil {
t.Fatalf("create directory: %v", err)
}
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
if err := os.Symlink(outsideGo, filepath.Join(root, "sneaky.go")); err != nil {
t.Fatalf("create symlink: %v", err)
}
traversal, err := filepath.Rel(root, outsideGo)
if err != nil {
t.Fatalf("build traversal path: %v", err)
}
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
{name: "blocks parent traversal", path: traversal, wantErr: true},
{name: "blocks absolute escape", path: outsideGo, wantErr: true},
{name: "blocks symlink escape", path: "sneaky.go", wantErr: true},
{name: "blocks non-Go file", path: "notes.txt", wantErr: true},
{name: "blocks directories", path: "folder.go", wantErr: true},
{name: "blocks empty path", path: "", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ReadGoFile(root, tc.path)
if tc.wantErr {
if err == nil {
t.Fatalf("ReadGoFile(%q) succeeded, want refusal", tc.path)
}
t.Logf("blocked %q: %v", tc.path, err)
return
}
if err != nil {
t.Fatalf("ReadGoFile(%q): %v", tc.path, err)
}
if string(got) != tc.want {
t.Fatalf("ReadGoFile(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
Your reviewer now accepts regular Go source files inside its trusted root while refusing unrelated targets. Next, you'll cap the amount of file data a review can load.
Cap Review File Size
Your reviewer now blocks filesystem escapes. It also rejects paths that do not point to reviewable Go source.
A valid Go path can still identify a huge file. This step adds a 256 * 1024 byte ceiling before the file reaches memory or an AI prompt.
In this step, get ready to:
- Define the maximum number of bytes the reviewer accepts.
- Reject oversized files before root.ReadFile.
- Prove the limit with a named regression case.
Add the size boundary
The metadata returned by root.Stat includes the file size. Checking that value before root.ReadFile prevents an oversized file from being loaded.
- In security-lab/reader.go, locate the closing parenthesis below the import list.
- Find this code:
)
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
Where Does the Limit Go?
This is the current insertion point below the imports. A package-level constant here remains available to the production reader and its tests.
- Insert the limit above the ReadGoFile comment so this section matches:
)
const MaxReviewBytes int64 = 256 * 1024
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
Why Use int64?
The Size() metadata value uses int64. Matching that type gives MaxReviewBytes a direct comparison with the file size.
- In security-lab/reader.go, scroll to the directory guard below root.Stat.
- Find this code:
if info.IsDir() {
return nil, fmt.Errorf("%q is a directory", requestedPath)
}
What Does the Existing Guard Do?
This guard rejects directories after the target metadata is loaded. The size guard belongs directly below it so both checks happen before any file contents are read.
- Add the size check directly below the directory guard so this section matches:
if info.IsDir() {
return nil, fmt.Errorf("%q is a directory", requestedPath)
}
if info.Size() > MaxReviewBytes {
return nil, fmt.Errorf(
"%q is %d bytes, over the %d byte review limit",
requestedPath,
info.Size(),
MaxReviewBytes,
)
}
What Does the Size Guard Do?
- The info.Size() call reads the byte count from metadata.
- The MaxReviewBytes constant keeps the policy in one named value.
- The error return stops execution before root.ReadFile loads the contents.
- Save security-lab/reader.go.
Before you run the suite, do you expect the existing cases to keep passing after this new guard?
- Check the current regressions by running this command:
go test -v ./security-lab
What Should You See?
You should see every existing subtest pass. This confirms the new size guard compiles without changing the established path and file-type policies.
Seeing a Build or Test Failure?
Confirm that MaxReviewBytes sits below the import block. Check that the size guard sits after info.IsDir() and before root.ReadFile.
Compare the parentheses in the multiline fmt.Errorf call with the reference above.
Help me fix the size guard in reader.go.
✔️ Awesome, I've got everything!
Your production reader now checks the file size before reading its contents. Make sure security-lab/reader.go is saved.
ⓧ I'd like to double check the full code
package securitylab
import (
"fmt"
"os"
"path/filepath"
)
const MaxReviewBytes int64 = 256 * 1024
// ReadGoFile reads one Go source file without allowing the requested path to
// escape rootPath.
func ReadGoFile(rootPath, requestedPath string) ([]byte, error) {
if requestedPath == "" {
return nil, fmt.Errorf("file path is empty")
}
if filepath.Ext(requestedPath) != ".go" {
return nil, fmt.Errorf("only .go files are reviewable")
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("open review root: %w", err)
}
defer root.Close()
info, err := root.Stat(requestedPath)
if err != nil {
return nil, fmt.Errorf("inspect %q: %w", requestedPath, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%q is a directory", requestedPath)
}
if info.Size() > MaxReviewBytes {
return nil, fmt.Errorf(
"%q is %d bytes, over the %d byte review limit",
requestedPath,
info.Size(),
MaxReviewBytes,
)
}
data, err := root.ReadFile(requestedPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", requestedPath, err)
}
return data, nil
}
How to Compare This File
This reference shows the complete production reader after the size limit is added. Compare the constant and the guard placement with your saved file.
Add the oversized regression case
A regression test needs a file that crosses the boundary by the smallest possible amount. The MaxReviewBytes+1 fixture makes that boundary explicit.
- In security-lab/reader_test.go, locate the existing file fixtures inside TestReadGoFile.
- Find these lines:
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
mustWriteFile(t, filepath.Join(root, "notes.txt"), []byte("not Go source\n"))
What Do These Fixtures Represent?
The existing fixtures cover a permitted Go file and a denied text file. The oversized Go fixture belongs beside them because it tests a separate content policy.
- Insert the oversized fixture directly below notes.txt so the setup matches:
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
mustWriteFile(t, filepath.Join(root, "notes.txt"), []byte("not Go source\n"))
mustWriteFile(t, filepath.Join(root, "huge.go"), make([]byte, MaxReviewBytes+1))
How Is the Oversized File Built?
The make call creates a byte slice one byte above the limit. mustWriteFile saves those bytes as huge.go inside the temporary test root.
- In the denied test table, locate the directory and empty-path cases.
- Find these lines:
{name: "blocks directories", path: "folder.go", wantErr: true},
{name: "blocks empty path", path: "", wantErr: true},
Where Does the New Case Belong?
These rows describe policy refusals through a path and an expected error. The oversized case uses the same table shape.
- Insert the oversized-file case between the directory and empty-path cases so the table matches:
{name: "blocks directories", path: "folder.go", wantErr: true},
{name: "blocks oversized files", path: "huge.go", wantErr: true},
{name: "blocks empty path", path: "", wantErr: true},
What Does the New Case Prove?
The named row sends huge.go through the production reader. The shared test loop requires ReadGoFile to return an error for that path.
- Save security-lab/reader_test.go.
✔️ Awesome, I've got everything!
Your test setup now creates huge.go and expects the reader to refuse it. Make sure security-lab/reader_test.go is saved.
ⓧ I'd like to double check the full code
package securitylab
import (
"os"
"path/filepath"
"testing"
)
// naiveReadFile is intentionally vulnerable and exists only to demonstrate
// why joining a trusted root to an untrusted path is not a security boundary.
func naiveReadFile(root, requestedPath string) ([]byte, error) {
return os.ReadFile(filepath.Join(root, requestedPath))
}
func mustWriteFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestNaiveReaderLeaksThroughSymlink(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
link := filepath.Join(root, "sneaky.go")
if err := os.Symlink(outsideGo, link); err != nil {
t.Fatalf("create symlink: %v", err)
}
got, err := naiveReadFile(root, "sneaky.go")
if err != nil {
t.Fatalf("naive read: %v", err)
}
if string(got) != "package secret\n" {
t.Fatalf("got %q, want leaked secret", got)
}
t.Logf("LEAKED: %s", got)
}
func TestReadGoFile(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
mustWriteFile(t, filepath.Join(root, "agent.go"), []byte("package main\n"))
mustWriteFile(t, filepath.Join(root, "notes.txt"), []byte("not Go source\n"))
mustWriteFile(t, filepath.Join(root, "huge.go"), make([]byte, MaxReviewBytes+1))
if err := os.Mkdir(filepath.Join(root, "folder.go"), 0o700); err != nil {
t.Fatalf("create directory: %v", err)
}
outsideGo := filepath.Join(outside, "secret.go")
mustWriteFile(t, outsideGo, []byte("package secret\n"))
if err := os.Symlink(outsideGo, filepath.Join(root, "sneaky.go")); err != nil {
t.Fatalf("create symlink: %v", err)
}
traversal, err := filepath.Rel(root, outsideGo)
if err != nil {
t.Fatalf("build traversal path: %v", err)
}
tests := []struct {
name string
path string
want string
wantErr bool
}{
{name: "allows Go file", path: "agent.go", want: "package main\n"},
{name: "blocks parent traversal", path: traversal, wantErr: true},
{name: "blocks absolute escape", path: outsideGo, wantErr: true},
{name: "blocks symlink escape", path: "sneaky.go", wantErr: true},
{name: "blocks non-Go file", path: "notes.txt", wantErr: true},
{name: "blocks directories", path: "folder.go", wantErr: true},
{name: "blocks oversized files", path: "huge.go", wantErr: true},
{name: "blocks empty path", path: "", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ReadGoFile(root, tc.path)
if tc.wantErr {
if err == nil {
t.Fatalf("ReadGoFile(%q) succeeded, want refusal", tc.path)
}
t.Logf("blocked %q: %v", tc.path, err)
return
}
if err != nil {
t.Fatalf("ReadGoFile(%q): %v", tc.path, err)
}
if string(got) != tc.want {
t.Fatalf("ReadGoFile(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
How to Compare This File
This reference shows the final shared setup and denied table. Compare the huge.go fixture with its matching blocks oversized files row.
Run the full security suite
The final run executes the intentional vulnerable demonstration alongside every production-reader regression. It proves that the teaching exploit remains visible while the secure reader enforces the new limit.
Before you run the suite, do you expect the oversized case to reach the file-reading call?
- Run the complete verbose suite with this command:
go test -v ./security-lab
What Should You See?
- The test-only demonstration logs LEAKED: package secret.
- The output includes a passing blocks_oversized_files subtest.
- The final package summary is ok.
Is the Oversized Case Failing?
Confirm that huge.go contains MaxReviewBytes+1 bytes. Check that its table row has wantErr: true.
If the file is read successfully, confirm the info.Size() guard appears before root.ReadFile in security-lab/reader.go.
Help me debug the oversized-file regression test.
You have closed the final gap: the reviewer now refuses an oversized Go file before reading it.
Secret mission
Block a Nested Symlink Chain
Build a two-link symlink chain that ends at the outside secret file. Then prove that the existing filesystem boundary blocks the full chain without any production-code changes.
Clean Up Your Resources
Clean Up Your Resources
Everything you created stays on your Mac with no ongoing costs. Choose whether to keep the repository, pause your work, or delete the folder.
Resources you used:
- The local nextwork-go-tutor repository. This folder contains the harden-file-reader Git branch plus the security-lab package.
Keep everything running
No action needed. Choose this if you want to extend the security lab or keep the patch for future review.
- Leave the nextwork-go-tutor folder on your Mac with the harden-file-reader branch intact.
Pause - I'll come back to this later
Shut down the editor to pause your work. Your branch and files remain on your Mac until you return.
- Save any open files in Visual Studio Code.
- Close Visual Studio Code.
- Close macOS Terminal if it is open.
Delete - I don't want to use this again
Deleting the folder removes the local clone, branch, and security package in one step. No remote repository or account needs cleanup.
- Save any code you want to keep outside the nextwork-go-tutor folder.
- Close Visual Studio Code.
- Locate the local nextwork-go-tutor folder in Finder.
- Move the nextwork-go-tutor folder to Trash.
- Confirm the folder no longer appears in its original Finder location.
Nice Work!
Nice Work!
Great work! You hardened the Go tutor's code reviewer with a confined file-reading boundary.
You've learned how to:
- Reproduce a symbolic-link escape with a test-only reader that visibly leaks an outside Go file.
- Build ReadGoFile around os.Root to confine model-selected paths to one trusted directory tree.
- Prove each reader policy with named table-driven regression tests.
- Secret Mission: Block a nested symlink chain without changing production code.
Ready to quiz yourself?