Path traversal is when user input reaches a filesystem call and escapes the directory you meant to serve from.
Request ../../etc/passwd and path.join resolves it obligingly.
Don't strip, resolve and verify
The common attempt is to remove ../ from the input. It fails, and predictably:
....//, strip the inner../and you're left with../%2e%2e%2f. URL-encoded, decoded after your check..\\on WindowsDouble-encoded variants
Resolve the path and check where it landed:
The + path.sep matters. A plain startsWith(BASE) accepts /var/uploads-public, which is a different directory.
Better: don't use user input as a path
If you can avoid constructing paths from input, do.
The user supplies an identifier. You supply the filename. There's nothing to traverse.
This also solves a problem the path check doesn't: a resolved path can be perfectly inside your uploads directory and still belong to someone else. Traversal and authorisation are separate bugs and the second one is easy to miss while fixing the first.
Symlinks
path.resolve works on strings and knows nothing about the filesystem. A symlink inside your base directory pointing outside it passes the check.
If users can create files in that directory, use fs.realpath and check the result instead.
Uploads
Never use a client-supplied filename on disk. Generate your own, a UUID. And keep the original in the database for display. That removes traversal, collisions and a set of problems around unusual characters at the same time.
Where to look
This class has a recognisable shape, user input flowing into a filesystem call. So dataflow-based tools generally catch it. Grep for readFile, sendFile, createReadStream and unlink and check what reaches them.