Applications often let a caller identify a file: download an invoice, open an exported report, read a template, or retrieve an uploaded document. The dangerous version of this design treats a caller-controlled path as if it were already a permitted file.

A path can describe movement through a filesystem, not just a filename. If untrusted input is combined with an application directory without a reliable containment check, the resulting path may resolve somewhere outside that directory. A read operation can expose configuration or private data; a write or delete operation can have more serious consequences.

This weakness is commonly called path traversal or directory traversal. The defensive goal is simple to state: a caller may choose an allowed resource, but must not be able to use path syntax to escape the filesystem area assigned to that operation.

This article builds a practical mental model for that boundary. You will learn why filtering suspicious strings is fragile, when opaque file identifiers are simpler than paths, how to validate a resolved path, and where symbolic links and filesystem changes make the problem more subtle.

Treat a path as a request for filesystem authority

Suppose a service stores downloadable reports under one directory:

/srv/app/reports/

A request supplies a file reference, and the application combines it with that root:

allowed root + caller input -> filesystem path -> open file

The security requirement is not merely that the input looks like a filename. The requirement is:

The file actually opened must remain inside the directory the application intends to expose.

That distinction matters because path strings have structure. Components can refer to parent directories, inputs can be absolute rather than relative, different platforms recognize different separators, and multiple textual paths can identify the same filesystem location.

The filesystem ultimately acts on the resolved location, not on the developer’s visual impression of the original string.

This gives us the central mental model: validate the authority of the destination, not the appearance of the path text.

Start with the smallest design: do not accept paths

The easiest path-traversal problem to defend is one that the interface does not create.

Imagine that a user can download one of three generated reports. The application could accept a path:

GET /reports?file=quarterly/summary.pdf

But if callers do not need arbitrary filesystem navigation, a narrower interface is easier to reason about:

GET /reports?id=report_4821

The server resolves report_4821 through application data to a file location that the caller never controls directly:

report_4821 -> stored metadata -> /srv/app/reports/2026/q3/4821.pdf

The identifier still needs authorization. A user who knows another report ID must not automatically gain access to it. But the interface no longer gives the caller path syntax as part of the resource-selection mechanism.

This is an important defensive pattern: when the valid set of files is known or can be represented by application identifiers, map identifiers to server-controlled paths instead of accepting path fragments.

An allowlist can serve the same purpose for a small fixed set:

"terms"  -> /srv/app/public/terms.pdf
"guide"  -> /srv/app/public/guide.pdf

The caller chooses a key. The application chooses the path.

Why blocking suspicious path strings is fragile

When an application genuinely needs relative paths, developers sometimes try to remove dangerous-looking text before opening a file.

A rule such as “reject any input containing ../” captures one familiar traversal form, but it does not express the actual security property. It asks whether one spelling appears in the input rather than whether the resolved destination stays inside the allowed root.

Path interpretation can vary because of:

  • absolute versus relative paths;
  • platform-specific separators and path forms;
  • redundant components such as .;
  • decoding performed by different application layers;
  • symbolic links or other filesystem indirection.

Repeatedly stripping unwanted substrings is especially risky. A transformation can produce a dangerous path that was not present in the original spelling, and different layers may decode or normalize the same input differently.

A better design defines the allowed namespace first. If only simple filenames are valid, accept only the characters and structure that simple filenames require. If nested relative paths are required, resolve them with the platform’s path facilities and verify the resulting destination against an allowed root.

The defense should model what is permitted, not try to enumerate every spelling an attacker might invent.

Resolve first, then verify containment

Consider an application that intentionally supports nested report paths such as:

2026/q3/summary.pdf

The caller is allowed to select descendants of /srv/app/reports, but nothing outside it.

A useful abstract flow is:

input
  |
  v
parse or decode once
  |
  v
resolve relative to allowed root
  |
  v
canonical or normalized destination
  |
  v
verify destination is contained by allowed root
  |
  v
perform permitted file operation

The exact API differs by language and operating system, so production code should use the platform’s well-tested path functions rather than hand-written separator logic.

The containment check must also be path-aware. A raw string-prefix comparison is not enough. For example, these directories share a textual prefix:

/srv/app/reports
/srv/app/reports-old

A check equivalent to “destination starts with /srv/app/reports” could mistakenly accept the second location. The comparison must understand directory boundaries, typically by using path-component or relative-path operations supplied by the platform.

The intended invariant is:

resolved destination == allowed root
              OR
resolved destination is a descendant of allowed root

For an operation that must select a file rather than the directory itself, the first case can be rejected as well.

Canonicalization answers only part of the question

Canonicalization means converting a path to a standard representation of the location it identifies. Depending on the API and whether the target exists, this may resolve redundant components and may also resolve symbolic links.

Canonicalization is useful because validation should reason about the path the filesystem will interpret, not an unusual textual spelling. But canonicalization is not an authorization decision by itself.

A canonical path can still point outside the permitted directory. The application must compare the resolved destination with the permitted root and reject destinations that escape it.

There is another practical detail: APIs do not all use the word “canonical” in the same way. A lexical normalization function may simplify . and parent components without consulting the filesystem. A real-path function may require the target to exist and may resolve symbolic links. Those behaviors are not interchangeable.

Before choosing an API, answer two questions:

  1. Does this operation require the target to exist already?
  2. Must symbolic links be followed, forbidden, or constrained to remain inside the allowed area?

Those decisions determine what kind of resolution is appropriate.

A symbolic link is a filesystem object that refers to another path. This creates an important boundary condition.

Suppose the application permits this path:

/srv/app/reports/current/summary.pdf

If current is a symbolic link to a directory outside /srv/app/reports, lexical normalization alone can still leave the path looking like a descendant of the allowed root. Following the link may lead elsewhere.

If untrusted users can create or replace links inside the allowed directory, this becomes part of the threat model. The application may need to resolve links before checking containment, disallow links for the operation, use operating-system facilities that constrain path resolution, or isolate the storage so untrusted actors cannot alter directory structure.

The correct choice depends on the application. A read-only package of files managed by administrators has a different risk profile from a shared writable directory where another process can rename entries or create links.

The key lesson is that containment concerns the file the operating system reaches, not merely the components visible in the original string.

Avoid a check-then-use race

Even a correct containment check can become stale if the filesystem can change between validation and file access.

Consider this sequence:

1. resolve path
2. verify it is inside the allowed root
3. another actor changes a directory entry or link
4. open the path

The application checked one filesystem state but used another. This is a form of time-of-check to time-of-use problem.

Whether this is exploitable depends on who can modify the relevant directories and how the operating system resolves the final open. If the directory tree is immutable to untrusted actors during the operation, a straightforward resolve-and-check design may be sufficient.

For higher-risk writable environments, prefer filesystem APIs that let the program anchor operations to an already-open trusted directory and constrain how descendant components are resolved. The names and guarantees of these APIs are operating-system specific, so do not imitate them with string manipulation.

Another strong architectural option is isolation: keep sensitive files outside the writable tree and run the file-serving component with access only to the storage it actually needs. Then a path-validation mistake has a smaller reachable filesystem.

Apply authorization separately from path containment

Path containment and authorization answer different questions.

Containment asks:

Is this file inside the filesystem area this operation may reach?

Authorization asks:

May this caller perform this operation on this particular file?

A file can satisfy the first question and fail the second. If Alice’s and Bob’s reports both live under /srv/app/reports, keeping a path inside that directory does not prove that Alice may read Bob’s report.

For user-owned or tenant-owned resources, perform the application-level authorization check as well. A robust design often starts from an authorized application object and obtains its server-controlled storage location, rather than starting from an arbitrary path and trying to infer ownership afterward.

This separation keeps the security model clear: filesystem containment limits where the process can reach, while authorization limits what the authenticated caller may do.

Reads, writes, and extraction have different consequences

The same containment principle applies to several file operations, but the impact of failure changes.

For a read endpoint, escaping the intended root can disclose data the process can read. For a write operation, it can overwrite files outside the storage area. For deletion, it can remove unintended files. Archive extraction has a similar concern because archive entry names may themselves contain path structure.

That does not mean every operation should share one generic validation helper without thought. Creating a new file has different resolution behavior from opening an existing file. A real-path operation that requires the final target to exist cannot directly canonicalize a file that has not been created yet.

For creation, a common design is to validate and securely resolve the existing parent directory, constrain the new leaf name, and create the file using filesystem facilities appropriate to the platform. If other actors can modify the directory concurrently, use APIs that preserve the trusted-directory relationship through the actual create operation.

The invariant remains the same even though the implementation differs: the object ultimately read, created, replaced, or deleted must stay within the authority granted to that operation.

Verify the defense with boundary-focused tests

Tests should demonstrate the security property rather than merely exercise one famous attack string.

Start with expected cases:

  • a permitted file directly under the root;
  • a permitted nested file when nested paths are part of the interface;
  • a missing file and the application’s intended error behavior.

Then test boundary conditions:

  • a path that would resolve to a parent of the allowed root;
  • an absolute path when only relative paths are allowed;
  • a sibling directory whose name begins with the same text as the allowed root;
  • platform-specific separators and path forms supported by the deployment environment;
  • symbolic links, if they can exist in the relevant tree;
  • encoded input at the layer where decoding actually occurs.

The expected result is not “the filter noticed a suspicious string.” It is “no rejected input causes the file operation to reach outside the allowed root.”

Integration tests are valuable here because path behavior belongs partly to the operating system and filesystem. Unit tests for string helpers alone cannot verify link resolution or concurrent filesystem behavior.

Know what this control does not solve

Directory containment reduces the risk that untrusted file references escape an intended filesystem area. It does not make every file inside that area appropriate to expose.

It does not replace:

  • object-level authorization for private files;
  • file-content validation for uploaded or processed data;
  • safe handling of active content;
  • least-privilege permissions for the application process;
  • limits on file size, storage consumption, or expensive processing;
  • protection against races when untrusted actors can mutate the path during resolution.

These controls address different failure modes. Defense in depth is useful when a file operation handles sensitive data or runs in a shared writable environment: narrow the interface, constrain the filesystem namespace, authorize the resource, and limit the process’s operating-system permissions.

For a small fixed set of resources, an identifier-to-path mapping may be all the path-specific complexity you need. For a general file browser or archive service, stronger path-resolution and filesystem-isolation controls are justified because path navigation is part of the feature itself.

Conclusion

Path traversal is easiest to understand as an authority problem. The application intends to grant access to one filesystem area, while untrusted path syntax can cause the operating system to resolve a different location.

Prefer interfaces that accept opaque resource identifiers instead of paths. When callers genuinely need relative paths, decode them at a well-defined boundary, resolve them with platform path APIs, and verify path-aware containment under an allowed root. Include symbolic links and concurrent filesystem changes in the threat model when untrusted actors can modify the directory tree.

Most importantly, verify the destination the filesystem will use. A path that looks harmless is not the security property; staying inside the intended authority boundary is.