A path is a string about a string, and every traversal defect is a disagreement between two of them: the path a check looked at, and the path a read actually used. When those are the same string there is nothing to exploit; the entire class is the gap between them.
This build serves files from a per-customer directory. Ask it for one and watch the resolver in the transcript — it reports what it was given, what that resolved to, and whether the answer was still inside the root.
GET /files/README.txtAn ordinary read. Look at the resolved path beside the requested one.
GET /files/../../etc/passwdThe same resolver, given something that climbs. Both builds resolve it identically — only one of them then reads it.
So the fix is an order rather than a filter: resolve, then check. The version everybody writes first is the other way round — reject anything containing .., then resolve — and it looks more careful, because it is doing the checking earlier.
It loses to anything that becomes a .. later. A percent-encoded one, a doubly encoded one, a backslash on a system that treats those as separators. The check passed because at the moment it ran, there really was no .. in the string.
GET /files/%252e%252e%252f%252e%252e%252fetc%252fpasswdNo `..` anywhere in what the check sees. Follow the requested path through to the resolved one.