A file system will happily answer to more than one name for the same file. secret.jsp, secret.jsp., secret.jsp%20 and dir/./secret.jsp can all land on one object. CWE-41, Improper Resolution of Path Equivalence, is what happens when a product guards one spelling and forgets the others.
MITRE defines path equivalence as the use of special characters in file and directory names to generate multiple names for the same object. It is usually employed to get around access controls that list an incomplete set of file name or path representations.
That last point is what separates it from path traversal. Per MITRE, traversal manipulates the path to name a different object. Equivalence names the same object in a form the protection did not expect.
It affects web servers, application servers, proxies, file servers and any code that makes an access decision from a path string. The weakness is not language specific.
How it happens
There are two steps and both have to be present. First, a rule is written against a particular string: deny /admin/config.xml, serve .jsp files as code, require a login for /private/. Second, the check runs on the raw input, and a different layer later normalizes the path and opens the file.
Whenever those two components disagree about what a name means, a gap opens. Many child weaknesses describe individual disagreements: trailing dots, trailing spaces, extra slashes, /./ segments, wildcards, fakedir/../realdir and Windows 8.3 short names.
The record's examples show the effect on real products. Source code disclosure using a trailing dot appears four times (CVE-2000-1114, CVE-2002-1986, CVE-2004-2213, CVE-2005-3293). In CVE-2004-2213 the trailing form was a dot or a trailing encoded space, %20. A server that would execute a script file instead returned its source, because the rule that selected the script handler did not match the altered name.
Here is a simplified vulnerable pattern:
# Vulnerable: access check on the raw string
def handle(path):
if path.startswith("/admin/") and not session.is_admin:
return deny()
return serve(os.path.join(DOCROOT, path)) # serve() normalizes later
A request for //admin/panel or /./admin/panel fails the startswith test, yet serve normalizes it to the protected file.
What an attacker gains
MITRE lists reading and modifying files or directories, and bypassing a protection mechanism. In practice, the most common results in the record are source code disclosure and getting around authentication or directory restrictions. Source code disclosure leaks logic, credentials in configuration and the next bug to try.
How to find it
MITRE cites a SOAR source for several detection techniques: automated and manual static analysis of binary or bytecode, and dynamic analysis with automated or manual interpretation of results. Translated into practice:
- List every place a decision (allow, deny, route, authenticate) is made from a path or URL string.
- Check whether that decision runs before or after canonicalization. Before is the red flag.
- Test protected resources with variants: trailing dot, trailing space or encoded space, repeated slashes, a
/./segment, afakedir/..prefix, a wildcard character and, on Windows, a short 8.3 name. - Run dynamic web scanning against a staging copy and review any variant that returns content it should not.
Click through it at your own pace, or open it full screen.
How to fix it
The core rule is simple to state: decide on the canonical form, not on the input.
- Canonicalize first. Convert the path to its single canonical form with the platform API, then apply access rules to the result.
- Use one component to normalize and open. If a front layer checks and a back layer resolves, put a canonical check at the layer that touches the file.
- Prefer allowlists. Accept only known good names or characters instead of trying to list every bad spelling. MITRE warns that denylists are likely to miss at least one undesirable input, and that filtering that strips dangerous characters is equivalent to a denylist.
- Reject odd characters in filenames: trailing dots and spaces, wildcards, repeated separators.
- Keep protected content out of the web root and enforce permissions at the file system as well as in the application.
# Fixed: canonicalize, then decide
import os
real = os.path.realpath(os.path.join(DOCROOT, path.lstrip("/")))
if not real.startswith(os.path.realpath(DOCROOT) + os.sep):
return deny()
if real.startswith(os.path.realpath(DOCROOT + "/admin") + os.sep) and not session.is_admin:
return deny()
return serve(real)
If your stack uses an off the shelf server or framework, the same class of bug can ship in a dependency. The free in-browser scan at gold.safeguard.sh/scan needs no login and the file stays on your machine, up to 100 dependencies.
Quick check
How does path equivalence differ from path traversal?
Traversal manipulates a path to name a different object. Equivalence generates another name for the same object so that a rule written for one spelling misses it.
Why is a denylist a poor fix?
A list of bad spellings is almost always incomplete. Canonicalizing the path and checking the result, or using an allowlist, closes the whole class.
Which order is safe, normalize then check, or check then normalize?
Normalize then check. Checking the raw string and normalizing later is how the gap opens.