In the two previous lessons we have written paths like this: 'data/events.json', `${BASE_DIR}/${month}`, file.slice(file.lastIndexOf('/') + 1). All of that works on your machine, today, running from the project root. And all of that is wrong.
It breaks if somebody runs the program from another folder. It breaks on Windows. It breaks if a name carries a space in the wrong place. And, in the worst case, it lets a malicious user escape the directory you thought you were serving and walk away with system files.
The path module exists to eliminate that entire class of bugs. It computes nothing from the disk — path is pure string handling and never touches the file system — but it knows the rules of each platform. By the end of this lesson you will have the Escena Viva paths centralized in src/config/paths.js, the two previous lessons refactored, and you will know how to harden a path built from data that comes from outside.
Contents
- Why concatenating paths is a mistake
joinversusresolve: the exact difference- Taking paths apart:
basename,dirname,extname,parse,format process.cwd()versus__dirname: the classic mistakesep,posixandwin32normalizeandrelative- Security: path traversal
src/config/paths.jsand the Escena Viva refactor
- Why concatenating paths is a mistake
Gluing strings looks harmless until you stop controlling the pieces:
const directory = 'reports/';
const name = '2026-08';
directory + '/' + name; // 'reports//2026-08' <- duplicated slashThe concrete problems are four and they all show up late:
| Problem | Example | Consequence |
|---|---|---|
| Duplicated or missing separators | 'reports//2026-08', 'reportsdata' |
Works sometimes; in string comparisons, never |
| Wrong separator | 'reports\\2026-08' on Linux |
The file name contains a literal backslash |
Unresolved .. |
'reports/2026-08/../2026-07' |
Two different paths point to the same place and nobody notices |
| Relative against absolute | '/etc/passwd' glued after a prefix |
The leading slash restarts the path and cancels your base directory |
That last one is the doorway to the security hole in section 7. The rule is simple and admits no exceptions: to build a path, path.join or path.resolve; never the + operator or a template string.
const path = require('node:path');
path.join('reports/', '/2026-08', 'occupancy.json');
// 'reports/2026-08/occupancy.json' <- normalized slashes
join versus resolve: the exact difference
join versus resolve: the exact differenceBoth combine path fragments, but they answer different questions:
path.join(...)glues the segments and normalizes the result. If the result is relative, it stays relative.path.resolve(...)always builds an absolute path, processing the arguments from right to left and stopping as soon as it has formed an absolute path. If it runs out of arguments without getting there, it prependsprocess.cwd().
With the process running in /home/ana/escena-viva:
| Call | path.join |
path.resolve |
|---|---|---|
('data', 'events.json') |
data/events.json |
/home/ana/escena-viva/data/events.json |
('/data', 'events.json') |
/data/events.json |
/data/events.json |
('data', '/events.json') |
data/events.json |
/events.json |
('reports', '..', 'data') |
data |
/home/ana/escena-viva/data |
('reports', '../..', 'x') |
../x |
/home/ana/x |
() (no arguments) |
. |
/home/ana/escena-viva |
The two highlighted rows are the important ones. In the third, resolve discards everything to its left the moment it finds an absolute argument: path.resolve('data', '/events.json') is /events.json, not data/events.json. join, on the other hand, treats the leading slash as a plain separator and absorbs it.
And in the fifth row, join keeps the .. that sticks out beyond the starting point (it cannot know where it is), while resolve applies it to a real path and it disappears.
| Use it when... | Function |
|---|---|
| You compose segments you already know to be relative to each other (a file name inside a folder) | join |
| You need an absolute, definitive path (opening a file, comparing paths, validating) | resolve |
| You are about to check that a path falls inside an allowed directory | resolve, always |
- Taking paths apart:
basename, dirname, extname, parse, format
basename, dirname, extname, parse, formatconst file = '/home/ana/escena-viva/reports/2026-08/occupancy-2026-08-11.json';
path.basename(file); // 'occupancy-2026-08-11.json'
path.basename(file, '.json'); // 'occupancy-2026-08-11' <- without extension
path.dirname(file); // '/home/ana/escena-viva/reports/2026-08'
path.extname(file); // '.json' (with the dot)path.parse returns all the pieces at once, and path.format makes the return trip:
const parts = path.parse(file);
// {
// root: '/',
// dir: '/home/ana/escena-viva/reports/2026-08',
// base: 'occupancy-2026-08-11.json',
// ext: '.json',
// name: 'occupancy-2026-08-11'
// }
// Change the extension without editing text by hand:
path.format({ ...parts, base: undefined, ext: '.csv' });
// '/home/ana/escena-viva/reports/2026-08/occupancy-2026-08-11.csv'That base: undefined is no whim: format gives base priority over name + ext, so if you leave the original base in place your extension change is silently ignored. It is one of those traps that takes half an hour to track down.
Two warnings about extname: on a file with no dot it returns the empty string, and on archive.tar.gz it returns only '.gz', because it knows nothing about compound extensions.
process.cwd() versus __dirname: the classic mistake
process.cwd() versus __dirname: the classic mistakeThis section explains the most frequent and most baffling failure in file work with Node.
process.cwd() |
__dirname |
|
|---|---|---|
| What it is | Directory the process was launched from | Directory of the file containing that variable |
| It changes if... | The user runs from another folder | Never (unless you move the file) |
What an fs relative path resolves against |
This | Nothing: fs does not use it |
And here is the key that explains everything: every relative path you hand to fs is resolved against process.cwd(), not against the file where you wrote the line. Our src/catalog-data.js says 'data/events.json', and that means "data/events.json starting from wherever the user launched the process".
# From the project root: it works.
cd ~/escena-viva
node src/catalog.js
# ...full catalog...
# From anywhere else: it breaks.
cd ~
node escena-viva/src/catalog.js
# [catalog] data/events.json not foundThe program is the same, the file is where it should be, and it fails. Because in the second case process.cwd() is /home/ana and Node looks for /home/ana/data/events.json.
The consequence is more serious than it looks: the project will work in your terminal and fail in the server's automatic startup, in the nightly cron, in the Docker container of Module 11 and in the tests of Module 9, because in all those environments the working directory is decided by somebody else.
The solution is to anchor paths to the code, not to the working directory:
// BAD: depends on where the process is run from.
const file = 'data/events.json';
// GOOD: relative to the file, always the same.
// __dirname is src/, so we go up one level to the project root.
const file = path.join(__dirname, '..', 'data', 'events.json');When you do want process.cwd(): when the path is typed by the user on the command line (node tool.js reports/august.json), because there anything relative must be interpreted from where the user is, not from where your code lives. That is the complete rule: project data against __dirname; user arguments against process.cwd().
sep, posix and win32
sep, posix and win32path.sep is the platform separator: '/' on Linux and macOS, '\\' on Windows. It is useful for splitting a path into segments (file.split(path.sep)), but you do not need it to build: join already takes care of that.
The module also exposes two complete variants:
| Variant | Separator | When to force it |
|---|---|---|
path.posix |
/ |
URLs, paths inside a ZIP or a container, cloud storage keys, paths stored in a database |
path.win32 |
\ |
Manipulating Windows paths from another platform (deployment scripts) |
path (default) |
The platform's | Anything that is real disk access |
The distinction matters more than it seems. Windows accepts / as a separator when opening files, so fs works either way; the problem shows up when that path stops being a path and becomes an identifier: the URL of an event poster, the key of an object in remote storage, the name of an entry inside a ZIP. There, reports\2026-08\poster.png is not the same as reports/2026-08/poster.png, and nobody warns you.
// Real path on disk: the platform's.
const physicalPath = path.join(REPORTS_DIR, month, file);
// Identifier that will travel in a URL: always POSIX.
const storageKey = path.posix.join('reports', month, file);In Module 4, when serving static files, this distinction will be mandatory: URLs use / on every platform, with no exceptions.
normalize and relative
normalize and relativenormalize cleans a path: it collapses repeated separators, resolves . and .. textually and fixes the separator. What it does not do is touch the disk or resolve symbolic links — that is what fs.realpath is for — and that is why a textual .. can take you somewhere other than the real place if links are involved. join and resolve already normalize internally; normalize is for when you receive a path somebody else assembled.
relative answers "how do I get from here to there?":
path.relative('/home/ana/escena-viva/src', '/home/ana/escena-viva/data/events.json');
// '../data/events.json'
path.relative('/home/ana/escena-viva', '/etc/passwd');
// '../../../etc/passwd' <- starts with '..': the target is OUTSIDEThat second example is the basis of the security check that comes next: if the relative path from a base to a target starts with .., the target is outside the base.
- Security: path traversal
Path traversal is one of the oldest and most alive vulnerabilities on the web. It shows up whenever a path is built from data that comes from outside.
Picture the Escena Viva report downloader: the user asks for a file by name and the server hands it over.
// VULNERABLE. Do not do this.
async function downloadReport(requestedName) {
const file = path.join('reports', requestedName);
return fs.readFile(file, 'utf8');
}With requestedName = '2026-08/occupancy-2026-08-11.json' everything is fine. With this, not so much:
await downloadReport('../../../../etc/passwd');
// path.join('reports', '../../../../etc/passwd') -> '../../../etc/passwd'
// The process reads /etc/passwd and sends it over the network.join normalizes, but does not protect: it applies the .. with perfect correctness and takes you out of the directory. And there are more variants: an absolute requestedName (/etc/passwd) escapes even more easily with resolve, and attackers also try encodings (%2e%2e%2f), backslashes and null bytes.
The correct defense has three steps and does not rely on blacklists:
// src/utils/safe-path.js
// Resolves an externally requested path inside a base directory,
// guaranteeing that it does not escape from it.
const path = require('node:path');
function resolveWithin(baseDir, requestedPath) {
// 1. Absolute, definitive base.
const base = path.resolve(baseDir);
// 2. Resolve the target AGAINST the base. resolve applies the '..'.
const target = path.resolve(base, requestedPath);
// 3. Check the target is still inside. With path.relative:
// if it starts with '..' or is absolute, it has escaped.
const fromBase = path.relative(base, target);
const inside = fromBase !== '' &&
!fromBase.startsWith('..') &&
!path.isAbsolute(fromBase);
if (!inside) {
const error = new Error('Path outside the allowed directory');
error.appCode = 'PATH_NOT_ALLOWED';
throw error;
}
return target;
}
module.exports = { resolveWithin };Why this check is the right one:
- It validates the already resolved path, not the input string. It does not matter how it is written —
..,., repeated slashes, mixed separators: onceresolveis done there is only one canonical path left, and that is the one being judged. - It uses
path.relativeinstead oftarget.startsWith(base). Prefix comparison has a subtle flaw:/data/reports-privatestarts with/data/reportsand would pass the filter while being a different directory. Comparing prefixes forces you to remember to add the separator;relativenever gets it wrong. - It also rejects the empty string, which means "the base directory itself" and is almost never a valid file to serve.
One case remains that path cannot solve on its own: if inside reports/ there is a symbolic link pointing outside, the resolved path looks legitimate. To harden it completely you have to check the real target with fs.realpath and validate again, or simply not allow links in the directories being served. We will pick this defense back up in lesson 04-04, where the file name will be supplied literally by the URL of an HTTP request.
src/config/paths.js and the Escena Viva refactor
src/config/paths.js and the Escena Viva refactorWith all of the above, we can now fix the debt from the two previous lessons. The idea is to have a single file that knows where everything is, and to have the whole project consult it:
// src/config/paths.js
// Single source of truth about the location of the project's files.
// Every path is ABSOLUTE and anchored to the code, not to the directory
// the process happens to be run from.
const path = require('node:path');
// __dirname is <root>/src/config, so we go up two levels.
const ROOT = path.resolve(__dirname, '..', '..');
const DATA_DIR = path.join(ROOT, 'data');
const REPORTS_DIR = path.join(ROOT, 'reports');
const EVENTS_FILE = path.join(DATA_DIR, 'events.json');
const SALES_FILE = path.join(DATA_DIR, 'sales.csv');
module.exports = {
ROOT,
DATA_DIR,
REPORTS_DIR,
EVENTS_FILE,
SALES_FILE
};The changes in the rest of the project are small and one line each:
// src/catalog-data.js
const { EVENTS_FILE } = require('./config/paths.js');
// Before: const EVENTS_FILE = 'data/events.json';
// Now the constant comes from the configuration and is absolute.
const content = await fs.readFile(EVENTS_FILE, 'utf8');// src/reports/report-store.js
const path = require('node:path');
const { REPORTS_DIR } = require('../config/paths.js');
async function saveReport(content, { date = new Date(), overwrite = false } = {}) {
const { month, day } = partitionDate(date);
const directory = path.join(REPORTS_DIR, month);
const file = path.join(directory, `occupancy-${day}.json`);
// ...the rest stays the same
}And in the log rotation of exercise 3 from the previous lesson, the manual slicing disappears:
// Before:
// const directory = file.slice(0, file.lastIndexOf('/')) || '.';
// const base = file.slice(file.lastIndexOf('/') + 1);
const directory = path.dirname(file);
const base = path.basename(file);Four concrete advantages of centralizing:
- The project works from any directory, because everything hangs off
ROOT, which is computed from__dirname. - Moving a folder means editing one line, not hunting down twenty-three repeated strings across the code.
- The tests in Module 9 can replace the whole module with one pointing at a temporary directory.
- You can see at a glance which files the application touches, which is valuable information for security and deployment reviews.
Proof that the debt is settled:
cd /tmp && node ~/escena-viva/src/catalog.js --table
# The table comes out just as it does from the project root.Common Mistakes and Tips
- Building paths with
+or template strings. Alwayspath.joinorpath.resolve. - Believing
pathtouches the disk. It checks no existence and resolves no links: it is string manipulation. For the rest,fs.statandfs.realpath. - Using
path.resolvewith a segment that might be absolute. It discards everything before it. If the segment comes from outside, validate first withresolveWithin. - Validating with
target.startsWith(base). It lets sibling directories with a common prefix through. Usepath.relative. - Trusting
process.cwd()for project data. It works in your terminal and breaks incron, in Docker and in the tests. - Using
path.sepin URLs. URLs are POSIX on every platform:path.posix.join. - Tip: always store absolute paths in variables and internal objects. Convert to relative only when showing it to the user, with
path.relative(ROOT, file). - Tip: in ES modules
__dirnamedoes not exist; you get it withpath.dirname(fileURLToPath(import.meta.url)), as you saw in lesson 02-07.
Exercises
Exercise 1: path inspector
Write src/lab/inspect-path.js that takes a path from process.argv and prints on stdout a table with: the path as given, whether it is absolute, its resolution with resolve from process.cwd(), its dirname, basename, extname, the result of parse, and its relative path from ROOT. Try it with data/events.json, /etc/hosts and ../../x/y.txt, running it from two different directories and explaining the differences.
Exercise 2: test battery for resolveWithin
Write src/lab/test-safe-path.js that runs resolveWithin(REPORTS_DIR, input) over this list and shows for each case whether it was allowed or rejected: '2026-08/occupancy.json', '../data/events.json', '/etc/passwd', '2026-08/../2026-07/x.json', '..', '', './2026-08/./x.json' and '2026-08/../../../../etc/passwd'. Reason out each result before running it.
Exercise 3: migrating the report store
Refactor src/reports/report-store.js completely so that not a single path is built with strings: saveReport, listReports and purgeReports must use path.join over REPORTS_DIR, and listReports must also return a relativePath field computed with path.relative(ROOT, file), more readable for console output.
Solutions
Solution 1. What is interesting is not the code but the experiment. Run from the project root and from /tmp, the path data/events.json gives two different resolutions:
cd ~/escena-viva && node src/lab/inspect-path.js data/events.json
# resolved: /home/ana/escena-viva/data/events.json
cd /tmp && node ~/escena-viva/src/lab/inspect-path.js data/events.json
# resolved: /tmp/data/events.json/etc/hosts gives the same thing in both cases because it is already absolute, and ../../x/y.txt changes just like the first one. That is exactly the difference between process.cwd() and __dirname on a single screen. Note that path.parse('/etc/hosts') returns ext: '' and name: 'hosts': there is no extension to extract.
Solution 2. The results, with REPORTS_DIR as the base:
| Input | Result | Why |
|---|---|---|
2026-08/occupancy.json |
Allowed | Falls inside; relative gives 2026-08/occupancy.json |
../data/events.json |
Rejected | relative gives ../data/events.json, starts with .. |
/etc/passwd |
Rejected | resolve takes it as absolute and discards the base |
2026-08/../2026-07/x.json |
Allowed | The .. cancel out inside the base |
.. |
Rejected | Points to the base's parent |
'' (empty) |
Rejected | relative gives '': it is the base itself, not a file |
./2026-08/./x.json |
Allowed | The . disappear when normalizing |
2026-08/../../../../etc/passwd |
Rejected | Four levels up leave the base |
The practical conclusion: there is no need to enumerate the attacks. The validation does not look for .. or odd characters in the input; it resolves first and asks afterwards where it ended up. Any exotic encoding the attacker invents ends up, after resolve, as a canonical path judged like all the others.
Solution 3. The pattern repeats in all three functions: replace every template with path.join and add the relative path to the output.
const path = require('node:path');
const { ROOT, REPORTS_DIR } = require('../config/paths.js');
// In listReports, inside the file loop:
const filePath = path.join(directory, file.name);
const info = await fs.stat(filePath);
reports.push({
month,
file: file.name,
path: filePath, // absolute: to work with
relativePath: path.relative(ROOT, filePath), // relative: to display
sizeBytes: info.size,
modified: info.mtime.toISOString()
});Notice the criterion that appears here and is worth adopting across the whole project: absolute to operate, relative only to present. A console.table with seventy-character absolute paths is unreadable; a relative path stored in a variable and used later is a time bomb.
Conclusion
The Escena Viva paths no longer depend on where you run the program from. You have seen why concatenating path strings is a mistake — duplicated separators, wrong separator, unresolved .. and, above all, an absolute segment that cancels your base directory — and you know the exact difference between join, which glues and normalizes while preserving the relative character, and resolve, which processes from right to left until it forms an absolute path and discards everything before an absolute segment.
You can take paths apart with basename, dirname, extname, parse and format — with the trap of base beating name + ext — and you are clear on the classic mistake that makes a program work in your folder and fail from another: fs resolves relative paths against process.cwd(), not against the file where you wrote them. Hence the rule: project data anchored to __dirname; user arguments, against process.cwd().
You tell path.posix from path.win32 and you know the boundary lies in whether the path is disk access or an identifier that will travel in a URL. And you have built the defense against path traversal: resolve against the base and check with path.relative that the target has not escaped, instead of chasing .. in the input or comparing text prefixes.
Escena Viva now has src/config/paths.js with ROOT, DATA_DIR, REPORTS_DIR, EVENTS_FILE and SALES_FILE, and the two previous lessons are refactored to use it. The project runs correctly from any directory.
That SALES_FILE we declared points to a file that does not exist yet, and that is no coincidence. In Working with Streams comes the problem readFile cannot solve: a season's sales history does not fit in the V8 heap. We will see what a stream is and its four types, why they are EventEmitter objects of the kind you already know, what backpressure really means and what happens when it is ignored, and we will process data/sales.csv line by line with constant memory.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
